Compare commits

..

14 Commits

Author SHA1 Message Date
Kamran Ahmed
3bfb806e5c UI changes on progress 2023-08-02 20:49:43 +01:00
Kamran Ahmed
4e0f7e3a3a Update 2023-08-02 20:45:28 +01:00
Kamran Ahmed
a7752e6621 Remove event binding for progress 2023-08-02 20:34:16 +01:00
Kamran Ahmed
85d6d54a92 Progress modal refactoring 2023-08-02 20:32:02 +01:00
Kamran Ahmed
b1e5be7e3a Update 2023-08-02 20:05:10 +01:00
Kamran Ahmed
55193b98bb Update 2023-08-02 19:58:56 +01:00
Kamran Ahmed
3f8aa30fc6 Progress modal 2023-08-02 19:58:29 +01:00
Kamran Ahmed
326ec019e3 Progress button design 2023-08-02 19:56:34 +01:00
Arik Chakma
8294566ee7 fix: classname 2023-08-01 01:59:15 +06:00
Arik Chakma
b66ec1b46b chore: prettier 2023-08-01 01:50:52 +06:00
Arik Chakma
a5678cb50c chore: best practices share 2023-08-01 01:45:54 +06:00
Arik Chakma
63c1f56d35 wip: share progress 2023-08-01 01:21:16 +06:00
Arik Chakma
272ed7ec61 wip: modal loading state 2023-07-31 15:02:38 +06:00
Arik Chakma
06c12fdb72 wip: user progress modal 2023-07-31 14:54:43 +06:00
381 changed files with 14565 additions and 23394 deletions

1
.npmrc
View File

@@ -1 +0,0 @@
auto-install-peers=true

View File

@@ -1,4 +1,5 @@
// https://astro.build/config
import preact from '@astrojs/preact';
import sitemap from '@astrojs/sitemap';
import tailwind from '@astrojs/tailwind';
import compress from 'astro-compress';
@@ -7,8 +8,6 @@ import rehypeExternalLinks from 'rehype-external-links';
import { fileURLToPath } from 'node:url';
import { serializeSitemap, shouldIndexPage } from './sitemap.mjs';
import react from '@astrojs/react';
// https://astro.build/config
export default defineConfig({
site: 'https://roadmap.sh/',
@@ -32,9 +31,11 @@ export default defineConfig({
'https://cs.fyi',
'https://roadmap.sh',
];
if (whiteListedStarts.some((start) => href.startsWith(start))) {
return [];
}
return 'noopener noreferrer nofollow';
},
},
@@ -45,6 +46,22 @@ export default defineConfig({
format: 'file',
},
integrations: [
{
name: 'client-authenticated',
hooks: {
'astro:config:setup'(options) {
options.addClientDirective({
name: 'authenticated',
entrypoint: fileURLToPath(
new URL(
'./src/directives/client-authenticated.mjs',
import.meta.url
)
),
});
},
},
},
tailwind({
config: {
applyBaseStyles: false,
@@ -55,10 +72,9 @@ export default defineConfig({
serialize: serializeSitemap,
}),
compress({
HTML: false,
CSS: false,
JavaScript: false,
css: false,
js: false,
}),
react(),
preact(),
],
});

View File

@@ -30,12 +30,11 @@ Find [the content directory inside the relevant roadmap](https://github.com/kamr
## Guidelines
- <p><strong>Adding everything available out there is not the goal!</strong><br />
- <p><strong>Adding everything available out there is not the goal!</strong><br />
The roadmaps represent the skillset most valuable today, i.e., if you were to enter any of the listed fields today, what would you learn?! There might be things that are of-course being used today but prioritize the things that are most in demand today, e.g., agreed that lots of people are using angular.js today but you wouldn't want to learn that instead of React, Angular, or Vue. Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included.</p>
- <p><strong>Do not add things you have not evaluated personally!</strong><br />
- <p><strong>Do not add things you have not evaluated personally!</strong><br />
Use your critical thinking to filter out non-essential stuff. Give honest arguments for why the resource should be included. Have you read this book? Can you give a short article?</p>
- <p><strong>Create a Single PR for Content Additions</strong></p>
If you are planning to contribute by adding content to the roadmaps, I recommend you to clone the repository, add content to the [content directory of the roadmap](./src/data/roadmaps/) and create a single PR to make it easier for me to review and merge the PR.
If you are planning to contribute by adding content to the roadmaps, I recommend you to clone the repository, add content to the [content directory of the roadmap](./content/roadmaps/) and create a single PR to make it easier for me to review and merge the PR.
- Write meaningful commit messages
- Look at the existing issues/pull requests before opening new ones

View File

@@ -4,7 +4,7 @@
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "astro dev --port 3000",
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
@@ -21,36 +21,28 @@
"test:e2e": "playwright test"
},
"dependencies": {
"@astrojs/react": "^3.0.0",
"@astrojs/preact": "^2.2.1",
"@astrojs/sitemap": "^1.3.3",
"@astrojs/tailwind": "^5.0.0",
"@astrojs/tailwind": "^3.1.3",
"@fingerprintjs/fingerprintjs": "^3.4.1",
"@nanostores/react": "^0.7.1",
"@types/react": "^18.0.21",
"@types/react-dom": "^18.0.6",
"astro": "^3.0.5",
"astro-compress": "^2.0.8",
"dracula-prism": "^2.1.13",
"@nanostores/preact": "^0.5.0",
"astro": "^2.6.6",
"astro-compress": "^1.1.47",
"jose": "^4.14.4",
"js-cookie": "^3.0.5",
"lucide-react": "^0.274.0",
"nanostores": "^0.9.2",
"node-html-parser": "^6.1.5",
"npm-check-updates": "^16.10.12",
"prismjs": "^1.29.0",
"react": "^18.0.0",
"react-confetti": "^6.1.0",
"react-dom": "^18.0.0",
"preact": "^10.15.1",
"rehype-external-links": "^2.1.0",
"roadmap-renderer": "^1.0.6",
"slugify": "^1.6.6",
"tailwindcss": "^3.3.3"
"tailwindcss": "^3.3.2"
},
"devDependencies": {
"@playwright/test": "^1.35.1",
"@tailwindcss/typography": "^0.5.9",
"@types/js-cookie": "^3.0.3",
"@types/prismjs": "^1.26.0",
"csv-parser": "^3.0.0",
"gh-pages": "^5.0.0",
"js-yaml": "^4.1.0",

2197
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -35,9 +35,7 @@ Here is the list of available roadmaps with more being actively worked upon.
- [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)
- [AI and Data Scientist Roadmap](https://roadmap.sh/ai-data-scientist)
- [QA Roadmap](https://roadmap.sh/qa)
- [Python Roadmap](https://roadmap.sh/python)
- [Software Architect Roadmap](https://roadmap.sh/software-architect)
- [Software Design and Architecture Roadmap](https://roadmap.sh/software-design-architecture)
- [JavaScript Roadmap](https://roadmap.sh/javascript)
@@ -51,6 +49,7 @@ Here is the list of available roadmaps with more being actively worked upon.
- [GraphQL Roadmap](https://roadmap.sh/graphql)
- [Android Roadmap](https://roadmap.sh/android)
- [Flutter Roadmap](https://roadmap.sh/flutter)
- [Python Roadmap](https://roadmap.sh/python)
- [Go Roadmap](https://roadmap.sh/golang)
- [Java Roadmap](https://roadmap.sh/java)
- [Spring Boot Roadmap](https://roadmap.sh/spring-boot)

View File

@@ -53,12 +53,12 @@ function prepareDirTree(control, dirTree, dirSortOrders) {
const sortOrder = controlName.match(/^\d+/)?.[0];
// No directory for a group without control name
if (!controlName || (!sortOrder && !controlName.startsWith('check:'))) {
if (!controlName || !sortOrder) {
return;
}
// e.g. testing-your-apps:other-options
const controlNameWithoutSortOrder = controlName.replace(/^\d+-/, '').replace(/^check:/, '');
const controlNameWithoutSortOrder = controlName.replace(/^\d+-/, '');
// e.g. ['testing-your-apps', 'other-options']
const dirParts = controlNameWithoutSortOrder.split(':');

View File

@@ -1,7 +1,6 @@
---
import AstroIcon from './AstroIcon.astro';
import { TeamDropdown } from './TeamDropdown/TeamDropdown';
import { SidebarFriendsCounter } from './Friends/SidebarFriendsCounter';
export interface Props {
activePageId: string;
@@ -22,16 +21,6 @@ const sidebarLinks = [
classes: 'h-3 w-4',
},
},
{
href: '/account/friends',
title: 'Friends',
id: 'friends',
isNew: true,
icon: {
glyph: 'users',
classes: 'h-4 w-4',
},
},
{
href: '/account/road-card',
title: 'Card',
@@ -143,17 +132,11 @@ const sidebarLinks = [
{sidebarLink.title}
</span>
{sidebarLink.isNew &&
sidebarLink.id !== 'friends' &&
!isActive && (
<span class='relative mr-1 flex items-center'>
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
</span>
)}
{sidebarLink.id === 'friends' && (
<SidebarFriendsCounter client:load />
{sidebarLink.isNew && !isActive && (
<span class='relative mr-1 flex items-center'>
<span class='relative rounded-full bg-gray-200 p-1 text-xs' />
<span class='absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs' />
</span>
)}
</a>
</li>
@@ -166,12 +149,7 @@ const sidebarLinks = [
}
<!-- /End Desktop Sidebar -->
<div
class:list={[
'grow px-0 py-0 md:py-10',
{ 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar },
]}
>
<div class:list={['grow px-0 py-0 md:py-10', { 'md:px-10': hasDesktopSidebar, 'md:px-5': !hasDesktopSidebar }]}>
<slot />
</div>
</div>

View File

@@ -21,11 +21,11 @@ function ActivityCounter(props: ActivityCounterType) {
const { text, count } = props;
return (
<div className="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
<h2 className="text-base sm:text-5xl font-bold">
<div class="relative flex flex-1 flex-row-reverse sm:flex-col px-0 sm:px-4 py-2 sm:py-4 text-center sm:pt-10 items-center gap-2 sm:gap-0 justify-end">
<h2 class="text-base sm:text-5xl font-bold">
{count}
</h2>
<p className="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
<p class="mt-0 sm:mt-2 text-sm text-gray-400">{text}</p>
</div>
);
}
@@ -34,8 +34,8 @@ export function ActivityCounters(props: ActivityCountersType) {
const { done, learning, streak } = props;
return (
<div className="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
<div className="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
<div class="mx-0 -mt-5 sm:-mx-10 md:-mt-10">
<div class="flex flex-col sm:flex-row gap-0 sm:gap-2 divide-y sm:divide-y-0 divide-x-0 sm:divide-x border-b">
<ActivityCounter
text={'Topics Completed'}
count={`${done?.total || 0}`}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import { httpGet } from '../../lib/http';
import { ActivityCounters } from './ActivityCounters';
import { ResourceProgress } from './ResourceProgress';
@@ -91,16 +91,16 @@ export function ActivityPage() {
streak={activity?.streak || { count: 0 }}
/>
<div className="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
<div class="mx-0 px-0 py-5 md:-mx-10 md:px-8 md:py-8">
{learningRoadmaps.length === 0 &&
learningBestPractices.length === 0 && <EmptyActivity />}
{(learningRoadmaps.length > 0 || learningBestPractices.length > 0) && (
<>
<h2 className="mb-3 text-xs uppercase text-gray-400">
<h2 class="mb-3 text-xs uppercase text-gray-400">
Continue Following
</h2>
<div className="flex flex-col gap-3">
<div class="flex flex-col gap-3">
{learningRoadmaps
.sort((a, b) => {
const updatedAtA = new Date(a.updatedAt);
@@ -110,7 +110,6 @@ export function ActivityPage() {
})
.map((roadmap) => (
<ResourceProgress
key={roadmap.id}
doneCount={roadmap.done || 0}
learningCount={roadmap.learning || 0}
totalCount={roadmap.total || 0}

View File

@@ -2,21 +2,21 @@ 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">
<div class="rounded-md">
<div class="flex flex-col items-center p-7 text-center">
<img
alt="no roadmaps"
src={RoadmapIcon.src}
className="mb-2 w-[60px] h-[60px] sm:h-[120px] sm:w-[120px] opacity-10"
src={RoadmapIcon}
class="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>
<h2 class="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{' '}
<a href="/roadmaps" className="mt-4 text-blue-500 hover:underline">
<a href="/roadmaps" class="mt-4 text-blue-500 hover:underline">
Roadmaps
</a>{' '}
or{' '}
<a href="/best-practices" className="mt-4 text-blue-500 hover:underline">
<a href="/best-practices" class="mt-4 text-blue-500 hover:underline">
Best Practices
</a>{' '}
progress.

View File

@@ -1,8 +1,8 @@
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import { getRelativeTimeString } from '../../lib/date';
import { useToast } from '../../hooks/use-toast';
import { ProgressShareButton } from '../UserProgress/ProgressShareButton';
import { useState } from 'react';
type ResourceProgressType = {
resourceType: 'roadmap' | 'best-practice';

View File

@@ -1,4 +1,4 @@
import { useRef, useState } from 'react';
import { useRef, useState } from 'preact/hooks';
import { useOutsideClick } from '../hooks/use-outside-click';
import { OptionType, SearchSelector } from './SearchSelector';
import type { PageType } from './CommandMenu/CommandMenu';
@@ -65,15 +65,15 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
)?.title;
return (
<div className="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
<div class="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyEl}
className="popup-body relative rounded-lg bg-white p-4 shadow"
class="popup-body relative rounded-lg bg-white p-4 shadow"
>
{isLoading && (
<>
<div className="flex items-center justify-center gap-2 py-8">
<div class="flex items-center justify-center gap-2 py-8">
<Spinner isDualRing={false} className="h-4 w-4" />
<h2 className="font-medium">Loading...</h2>
</div>
@@ -82,7 +82,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
{!isLoading && !error && selectedRoadmap && (
<div className={'text-center'}>
<CheckIcon additionalClasses="h-10 w-10 mx-auto opacity-20 mb-3 mt-4" />
<h3 className="mb-1.5 text-2xl font-medium">
<h3 class="mb-1.5 text-2xl font-medium">
{selectedRoadmapTitle} Added
</h3>
<p className="mb-4 text-sm leading-none text-gray-400">
@@ -95,11 +95,11 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
to make changes to the roadmap.
</p>
<div className="flex items-center gap-2">
<div class="flex items-center gap-2">
<button
onClick={onClose}
type="button"
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Done
</button>
@@ -110,7 +110,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
setIsLoading(false);
}}
type="button"
className="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
class="flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white"
>
+ Add More
</button>
@@ -119,14 +119,14 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
)}
{!isLoading && error && (
<>
<h3 className="mb-1.5 text-2xl font-medium">Error</h3>
<h3 class="mb-1.5 text-2xl font-medium">Error</h3>
<p className="mb-3 text-sm leading-none text-red-400">{error}</p>
<div className="flex items-center gap-2">
<div class="flex items-center gap-2">
<button
onClick={onClose}
type="button"
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Cancel
</button>
@@ -135,7 +135,7 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
)}
{!isLoading && !error && !selectedRoadmap && (
<>
<h3 className="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
<h3 class="mb-1.5 text-2xl font-medium">Add Roadmap</h3>
<p className="mb-3 text-sm leading-none text-gray-400">
Search and add a roadmap
</p>
@@ -156,11 +156,11 @@ export function AddTeamRoadmap(props: AddTeamRoadmapProps) {
placeholder={'Search for roadmap'}
/>
<div className="flex items-center gap-2">
<div class="flex items-center gap-2">
<button
onClick={onClose}
type="button"
className="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 py-2 text-center hover:bg-gray-300"
>
Cancel
</button>

View File

@@ -1,17 +1,17 @@
import Cookies from 'js-cookie';
import type { FormEvent } from 'react';
import { useState } from 'react';
import type { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
export function EmailLoginForm() {
const EmailLoginForm: FunctionComponent<{}> = () => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const handleFormSubmit = async (e: FormEvent<HTMLFormElement>) => {
const handleFormSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
@@ -29,7 +29,6 @@ export function EmailLoginForm() {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.reload();
@@ -77,7 +76,7 @@ export function EmailLoginForm() {
onInput={(e) => setPassword(String((e.target as any).value))}
/>
<p className="mb-3 mt-2 text-sm text-gray-500">
<p class="mb-3 mt-2 text-sm text-gray-500">
<a
href="/forgot-password"
className="text-blue-800 hover:text-blue-600"
@@ -99,4 +98,6 @@ export function EmailLoginForm() {
</button>
</form>
);
}
};
export default EmailLoginForm;

View File

@@ -1,7 +1,8 @@
import { type FormEvent, useState } from 'react';
import type { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
export function EmailSignupForm() {
const EmailSignupForm: FunctionComponent = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
@@ -9,7 +10,7 @@ export function EmailSignupForm() {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
const onSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
@@ -97,4 +98,6 @@ export function EmailSignupForm() {
</button>
</form>
);
}
};
export default EmailSignupForm;

View File

@@ -1,4 +1,4 @@
import { type FormEvent, useState } from 'react';
import { useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
export function ForgotPasswordForm() {
@@ -7,7 +7,7 @@ export function ForgotPasswordForm() {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
@@ -29,7 +29,7 @@ export function ForgotPasswordForm() {
};
return (
<form onSubmit={handleSubmit} className="w-full">
<form onSubmit={handleSubmit} class="w-full">
<input
type="email"
name="email"

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import GitHubIcon from '../../icons/github.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import Cookies from 'js-cookie';
@@ -61,7 +62,6 @@ export function GitHubButton(props: GitHubButtonProps) {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = redirectUrl;
})
@@ -90,11 +90,10 @@ export function GitHubButton(props: GitHubButtonProps) {
// 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'].includes(
window.location.pathname
)
? window.location.pathname + window.location.search
: window.location.pathname;
const pagePath =
window.location.pathname === '/respond-invite'
? window.location.pathname + window.location.search
: window.location.pathname;
localStorage.setItem(GITHUB_REDIRECT_AT, Date.now().toString());
localStorage.setItem(GITHUB_LAST_PAGE, pagePath);
@@ -106,14 +105,14 @@ export function GitHubButton(props: GitHubButtonProps) {
return (
<>
<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"
class="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}
onClick={handleClick}
>
<img
src={icon.src}
src={icon}
alt="GitHub"
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with GitHub
</button>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie';
import GoogleIcon from '../../icons/google.svg';
import SpinnerIcon from '../../icons/spinner.svg';
@@ -60,7 +60,6 @@ export function GoogleButton(props: GoogleButtonProps) {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = redirectUrl;
})
@@ -87,7 +86,7 @@ export function GoogleButton(props: GoogleButtonProps) {
// the user was on before they clicked the social login button
if (!['/login', '/signup'].includes(window.location.pathname)) {
const pagePath =
['/respond-invite', '/befriend'].includes(window.location.pathname)
window.location.pathname === '/respond-invite'
? window.location.pathname + window.location.search
: window.location.pathname;
@@ -106,14 +105,14 @@ export function GoogleButton(props: GoogleButtonProps) {
return (
<>
<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"
class="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}
onClick={handleClick}
>
<img
src={icon.src}
src={icon}
alt="Google"
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with Google
</button>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie';
import LinkedIn from '../../icons/linkedin.svg';
import SpinnerIcon from '../../icons/spinner.svg';
@@ -60,7 +60,6 @@ export function LinkedInButton(props: LinkedInButtonProps) {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = redirectUrl;
})
@@ -86,11 +85,10 @@ 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'].includes(
window.location.pathname
)
? window.location.pathname + window.location.search
: window.location.pathname;
const pagePath =
window.location.pathname === '/respond-invite'
? window.location.pathname + window.location.search
: window.location.pathname;
localStorage.setItem(LINKEDIN_REDIRECT_AT, Date.now().toString());
localStorage.setItem(LINKEDIN_LAST_PAGE, pagePath);
@@ -107,14 +105,14 @@ export function LinkedInButton(props: LinkedInButtonProps) {
return (
<>
<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"
class="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}
onClick={handleClick}
>
<img
src={icon.src}
src={icon}
alt="Google"
className={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
class={`h-[18px] w-[18px] ${isLoading ? 'animate-spin' : ''}`}
/>
Continue with LinkedIn
</button>

View File

@@ -1,6 +1,6 @@
---
import Popup from '../Popup/Popup.astro';
import { EmailLoginForm } from './EmailLoginForm';
import EmailLoginForm from './EmailLoginForm';
import Divider from './Divider.astro';
import { GitHubButton } from './GitHubButton';
import { GoogleButton } from './GoogleButton';
@@ -9,9 +9,9 @@ import { LinkedInButton } from './LinkedInButton';
<Popup id='login-popup' title='' subtitle=''>
<div class='text-center'>
<p class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
<h2 class='mb-3 text-2xl font-semibold leading-5 text-slate-900'>
Login to your account
</p>
</h2>
<p class='mt-2 text-sm leading-4 text-slate-600'>
You must be logged in to perform this action.
</p>

View File

@@ -1,9 +1,9 @@
import { type FormEvent, useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
export function ResetPasswordForm() {
export default function ResetPasswordForm() {
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [passwordConfirm, setPasswordConfirm] = useState('');
@@ -21,7 +21,7 @@ export function ResetPasswordForm() {
}
}, []);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
@@ -56,7 +56,6 @@ export function ResetPasswordForm() {
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,10 @@
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import { httpPost } from '../../lib/http';
import ErrorIcon from '../../icons/error.svg';
import SpinnerIcon from '../../icons/spinner.svg';
import ErrorIcon from '../../icons/error.svg';
import { useEffect, useState } from 'preact/hooks';
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { httpPost } from '../../lib/http';
export function TriggerVerifyAccount() {
const [isLoading, setIsLoading] = useState(true);
@@ -29,7 +30,6 @@ export function TriggerVerifyAccount() {
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
path: '/',
expires: 30,
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
window.location.href = '/';
})
@@ -58,14 +58,14 @@ export function TriggerVerifyAccount() {
{isLoading && (
<img
alt={'Please wait.'}
src={SpinnerIcon.src}
className={'mx-auto h-16 w-16 animate-spin'}
src={SpinnerIcon}
class={'mx-auto h-16 w-16 animate-spin'}
/>
)}
{error && (
<img
alt={'Please wait.'}
src={ErrorIcon.src}
src={ErrorIcon}
className={'mx-auto h-16 w-16'}
/>
)}
@@ -74,7 +74,7 @@ export function TriggerVerifyAccount() {
</h2>
<div className="text-sm sm:text-base">
{isLoading && <p>Please wait while we verify your account..</p>}
{error && <p className="text-red-700">{error}</p>}
{error && <p class="text-red-700">{error}</p>}
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import VerifyLetterIcon from '../../icons/verify-letter.svg';
import { useEffect, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
export function VerificationEmailMessage() {
@@ -39,13 +39,13 @@ export function VerificationEmailMessage() {
<div className="mx-auto max-w-md text-center">
<img
alt="Verify Email"
src={VerifyLetterIcon.src}
className="mx-auto mb-4 h-20 w-40 sm:h-40"
src={VerifyLetterIcon}
class="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">
<h2 class="my-2 text-center text-xl font-semibold sm:my-5 sm:text-2xl">
Verify your email address
</h2>
<div className="text-sm sm:text-base">
<div class="text-sm sm:text-base">
<p>
We have sent you an email at{' '}
<span className="font-bold">{email}</span>. Please click the link to
@@ -53,7 +53,7 @@ export function VerificationEmailMessage() {
soon!
</p>
<hr className="my-4" />
<hr class="my-4" />
{!isEmailResent && (
<>
@@ -72,12 +72,12 @@ export function VerificationEmailMessage() {
</p>
)}
{error && <p className="text-red-700">{error}</p>}
{error && <p class="text-red-700">{error}</p>}
</>
)}
{isEmailResent && (
<p className="text-green-700">Verification email has been sent!</p>
<p class="text-green-700">Verification email has been sent!</p>
)}
</div>
</div>

View File

@@ -37,14 +37,13 @@ function handleGuest() {
'/account/update-password',
'/account/settings',
'/account/road-card',
'/account/friends',
'/account',
'/team',
'/team/progress',
'/team/roadmaps',
'/team/new',
'/team/members',
'/team/settings',
'/team/settings'
];
showHideAuthElements('hide');

View File

@@ -1,368 +0,0 @@
import { useEffect, useState } from 'react';
import { httpDelete, httpGet, httpPost } from '../lib/http';
import { pageProgressMessage } from '../stores/page';
import { isLoggedIn } from '../lib/jwt';
import { showLoginPopup } from '../lib/popup';
import { getUrlParams } from '../lib/browser';
import { CheckIcon } from './ReactIcons/CheckIcon';
import { DeleteUserIcon } from './ReactIcons/DeleteUserIcon';
import { useToast } from '../hooks/use-toast';
import { useAuth } from '../hooks/use-auth';
import { AddedUserIcon } from './ReactIcons/AddedUserIcon';
import { StopIcon } from './ReactIcons/StopIcon';
import { ErrorIcon } from './ReactIcons/ErrorIcon';
export type FriendshipStatus =
| 'none'
| 'sent'
| 'received'
| 'accepted'
| 'rejected'
| 'got_rejected';
type UserResponse = {
id: string;
links: Record<string, string>;
avatar: string;
name: string;
status: FriendshipStatus;
};
export function Befriend() {
const { u: inviteId } = getUrlParams();
const toast = useToast();
const currentUser = useAuth();
const [isConfirming, setIsConfirming] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [user, setUser] = useState<UserResponse>();
const isAuthenticated = isLoggedIn();
async function loadUser(userId: string) {
const { response, error } = await httpGet<UserResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-friend/${userId}`
);
if (error || !response) {
setError(error?.message || 'Something went wrong');
return;
}
if (response.status === 'accepted') {
window.location.href = '/account/friends?c=fa';
return;
}
setUser(response);
}
useEffect(() => {
if (inviteId) {
loadUser(inviteId).finally(() => {
pageProgressMessage.set('');
setIsLoading(false);
});
} else {
setIsLoading(false);
setError('Missing invite ID in URL');
pageProgressMessage.set('');
}
}, [inviteId]);
async function addFriend(userId: string, successMessage: string) {
pageProgressMessage.set('Please wait...');
setError('');
const { response, error } = await httpPost<UserResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
{}
);
if (error || !response) {
setError(error?.message || 'Something went wrong');
return;
}
if (response.status === 'accepted') {
window.location.href = '/account/friends?c=fa';
return;
}
setUser(response);
}
async function deleteFriend(userId: string, successMessage: string) {
pageProgressMessage.set('Please wait...');
setError('');
const { response, error } = await httpDelete<UserResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
{}
);
if (error || !response) {
setError(error?.message || 'Something went wrong');
return;
}
setUser(response);
toast.success(successMessage);
}
if (isLoading) {
return null;
}
if (!user) {
return (
<div className="container text-center">
<ErrorIcon additionalClasses="mx-auto mb-4 mt-24 w-20 opacity-20" />
<h2 className={'mb-1 text-2xl font-bold'}>Error</h2>
<p className="mb-4 text-base leading-6 text-gray-600">
{error || 'There was a problem, please try again.'}
</p>
<div>
<a
href="/"
className="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
>
Back to home
</a>
</div>
</div>
);
}
const userAvatar = user.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${user.avatar}`
: '/images/default-avatar.png';
const isMe = currentUser?.id === user.id;
return (
<div className="container !max-w-[400px] text-center">
<img
alt={'join team'}
src={userAvatar}
className="mx-auto mb-4 mt-24 w-28 rounded-full"
/>
<h2 className={'mb-1 text-3xl font-bold'}>{user.name}</h2>
<p className="mb-6 text-base leading-6 text-gray-600">
After you add {user.name} as a friend, you will be able to view each
other's skills and progress.
</p>
<div className="mx-auto w-full duration-500 sm:max-w-md">
<div className="flex w-full flex-col items-center gap-2">
{user.status === 'none' && (
<button
disabled={isMe}
onClick={() => {
if (!isAuthenticated) {
return showLoginPopup();
}
addFriend(user.id, 'Friend request sent').finally(() => {
pageProgressMessage.set('');
});
}}
type="button"
className="w-full flex-grow cursor-pointer rounded-lg bg-black px-3 py-2 text-center text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{isMe ? "You can't add yourself" : 'Add Friend'}
</button>
)}
{user.status === 'sent' && (
<>
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
<CheckIcon additionalClasses="mr-2 h-4 w-4" />
Request Sent
</span>
{!isConfirming && (
<button
onClick={() => {
setIsConfirming(true);
}}
type="button"
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-red-600 px-3 py-2 text-center text-white hover:bg-red-700"
>
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
Withdraw Request
</button>
)}
{isConfirming && (
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
Are you sure?{' '}
<button
className="ml-2 text-red-700 underline"
onClick={() => {
deleteFriend(user.id, 'Friend request withdrawn').finally(
() => {
pageProgressMessage.set('');
}
);
}}
>
Yes
</button>{' '}
<button
onClick={() => {
setIsConfirming(false);
}}
className="ml-2 text-red-600 underline"
>
No
</button>
</span>
)}
</>
)}
{user.status === 'accepted' && (
<>
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
<AddedUserIcon additionalClasses="mr-2 h-5 w-5" />
You are friends
</span>
{!isConfirming && (
<button
onClick={() => {
setIsConfirming(true);
}}
type="button"
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-red-600 px-3 py-2 text-center text-white hover:bg-red-700"
>
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
Remove Friend
</button>
)}
{isConfirming && (
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
Are you sure?{' '}
<button
className="ml-2 text-red-700 underline"
onClick={() => {
deleteFriend(user.id, 'Friend removed').finally(() => {
pageProgressMessage.set('');
});
}}
>
Yes
</button>{' '}
<button
onClick={() => {
setIsConfirming(false);
}}
className="ml-2 text-red-600 underline"
>
No
</button>
</span>
)}
</>
)}
{user.status === 'rejected' && (
<>
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-gray-300 px-3 py-2 text-center text-black">
<DeleteUserIcon additionalClasses="mr-2 h-4 w-4" />
Request Rejected
</span>
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
Changed your mind?{' '}
<button
className="ml-2 text-red-700 underline"
onClick={() => {
addFriend(user.id, 'Friend request accepted').finally(
() => {
pageProgressMessage.set('');
}
);
}}
>
Click here to Accept
</button>
</span>
</>
)}
{user.status === 'got_rejected' && (
<>
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-500 px-3 py-2 text-center text-red-500">
<StopIcon additionalClasses="mr-2 h-4 w-4" />
Request Rejected
</span>
</>
)}
{user.status === 'received' && (
<>
<button
onClick={() => {
addFriend(user.id, 'Friend request accepted').finally(() => {
pageProgressMessage.set('');
});
}}
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-gray-800 bg-gray-800 px-3 py-2 text-center text-white hover:bg-black"
>
<CheckIcon additionalClasses="mr-2 h-4 w-4" />
Accept Request
</button>
{!isConfirming && (
<button
onClick={() => {
setIsConfirming(true);
}}
type="button"
className="flex w-full flex-grow cursor-pointer items-center justify-center rounded-lg border border-red-600 bg-white px-3 py-2 text-center text-red-600 hover:bg-red-100"
>
<DeleteUserIcon additionalClasses="mr-2 h-[19px] w-[19px]" />
Reject Request
</button>
)}
{isConfirming && (
<span className="flex w-full flex-grow cursor-default items-center justify-center rounded-lg border border-red-600 px-3 py-2.5 text-center text-sm text-red-600">
Are you sure?{' '}
<button
className="ml-2 text-red-700 underline"
onClick={() => {
deleteFriend(user.id, 'Friend request rejected').finally(
() => {
pageProgressMessage.set('');
}
);
}}
>
Yes
</button>{' '}
<button
onClick={() => {
setIsConfirming(false);
}}
className="ml-2 text-red-600 underline"
>
No
</button>
</span>
)}
</>
)}
</div>
</div>
{error && (
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">{error}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,44 @@
---
import type { BreadcrumbItem } from '../lib/roadmap-topic';
export interface Props {
breadcrumbs: BreadcrumbItem[];
roadmapId: string;
}
const { breadcrumbs, roadmapId } = Astro.props;
---
<div class='py-7 pb-6'>
<!-- Desktop breadcrumbs -->
<p class='text-gray-500 container hidden sm:block'>
{
breadcrumbs.map((breadcrumb, counter) => {
const isLast = counter === breadcrumbs.length - 1;
if (!isLast) {
return (
<>
<a class='hover:text-gray-800' href={`${breadcrumb.url}`}>
{breadcrumb.title}
</a>
<span>&nbsp;&middot;&nbsp;</span>
</>
);
}
return <span class='text-gray-400'>{breadcrumb.title}</span>;
})
}
</p>
<!-- Mobile breadcrums -->
<p class='container block sm:hidden'>
<a
class='bg-gray-500 py-1.5 px-3 rounded-md text-white text-xs sm:text-sm font-medium hover:bg-gray-600'
href={`/${roadmapId}`}
>
&larr; Back to Topics List
</a>
</p>
</div>

View File

@@ -1,8 +1,7 @@
import { Fragment, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'preact/hooks';
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';
@@ -23,13 +22,13 @@ export type PageType = {
};
const defaultPages: PageType[] = [
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon.src },
{ id: 'home', url: '/', title: 'Home', group: 'Pages', icon: HomeIcon },
{
id: 'account',
url: '/account',
title: 'Account',
group: 'Pages',
icon: UserIcon.src,
icon: UserIcon,
isProtected: true,
},
{
@@ -37,7 +36,7 @@ const defaultPages: PageType[] = [
url: '/team',
title: 'Teams',
group: 'Pages',
icon: GroupIcon.src,
icon: GroupIcon,
isProtected: true,
},
{
@@ -45,35 +44,28 @@ const defaultPages: PageType[] = [
url: '/roadmaps',
title: 'Roadmaps',
group: 'Pages',
icon: RoadmapIcon.src,
icon: RoadmapIcon,
},
{
id: 'best-practices',
url: '/best-practices',
title: 'Best Practices',
group: 'Pages',
icon: BestPracticesIcon.src,
},
{
id: 'questions',
url: '/questions',
title: 'Questions',
group: 'Pages',
icon: ClipboardIcon.src,
icon: BestPracticesIcon,
},
{
id: 'guides',
url: '/guides',
title: 'Guides',
group: 'Pages',
icon: GuideIcon.src,
icon: GuideIcon,
},
{
id: 'videos',
url: '/videos',
title: 'Videos',
group: 'Pages',
icon: VideoIcon.src,
icon: VideoIcon,
},
];
@@ -166,12 +158,12 @@ export function CommandMenu() {
<div className="relative rounded-lg bg-white shadow" ref={modalRef}>
<input
ref={inputRef}
autoFocus={true}
autofocus={true}
type="text"
value={searchedText}
className="w-full rounded-t-md border-b p-4 text-sm focus:bg-gray-50 focus:outline-none"
placeholder="Search roadmaps, guides or pages .."
autoComplete="off"
autocomplete="off"
onInput={(e) => {
const value = (e.target as HTMLInputElement).value.trim();
setSearchedText(value);
@@ -199,43 +191,39 @@ export function CommandMenu() {
}}
/>
<div className="px-2 py-2">
<div class="px-2 py-2">
<div className="flex flex-col">
{searchResults.length === 0 && (
<div className="p-5 text-center text-sm text-gray-400">
<div class="p-5 text-center text-sm text-gray-400">
No results found
</div>
)}
{searchResults.map((page: PageType, counter: number) => {
{searchResults.map((page, counter) => {
const prevPage = searchResults[counter - 1];
const groupChanged = prevPage && prevPage.group !== page.group;
return (
<Fragment key={page.id}>
<>
{groupChanged && (
<div className="border-b border-gray-100"></div>
<div class="border-b border-gray-100"></div>
)}
<a
className={`flex w-full items-center rounded p-2 text-sm ${
class={`flex w-full items-center rounded p-2 text-sm ${
counter === activeCounter ? 'bg-gray-100' : ''
}`}
onMouseOver={() => setActiveCounter(counter)}
href={page.url}
>
{!page.icon && (
<span className="mr-2 text-gray-400">{page.group}</span>
<span class="mr-2 text-gray-400">{page.group}</span>
)}
{page.icon && (
<img
alt={page.title}
src={page.icon}
className="mr-2 h-4 w-4"
/>
<img alt={page.title} src={page.icon} class="mr-2 h-4 w-4" />
)}
{page.title}
</a>
</Fragment>
</>
);
})}
</div>

View File

@@ -1,69 +0,0 @@
import { useEffect, useState } from 'react';
import ReactConfetti from 'react-confetti';
type ConfettiPosition = {
x: number;
y: number;
w: number;
h: number;
};
type ConfettiProps = {
pieces?: number;
element?: HTMLElement | null;
onDone?: () => void;
};
export function Confetti(props: ConfettiProps) {
const { element = document.body, onDone = () => null, pieces = 40 } = props;
const [confettiPos, setConfettiPos] = useState<
undefined | ConfettiPosition
>();
function populateConfettiPosition(element: HTMLElement) {
const elRect = element.getBoundingClientRect();
// set confetti position, keeping in mind the scroll values
setConfettiPos({
x: elRect?.x || 0,
y: (elRect?.y || 0) + window.scrollY,
w: elRect?.width || 0,
h: elRect?.height || 0,
});
}
useEffect(() => {
if (!element) {
setConfettiPos(undefined);
return;
}
populateConfettiPosition(element);
}, [element]);
if (!confettiPos) {
return null;
}
return (
<ReactConfetti
height={document.body.scrollHeight}
numberOfPieces={pieces}
recycle={false}
onConfettiComplete={(confettiInstance) => {
setConfettiPos(undefined);
onDone();
}}
initialVelocityX={4}
initialVelocityY={8}
tweenDuration={10}
confettiSource={{
x: confettiPos.x,
y: confettiPos.y,
w: confettiPos.w,
h: confettiPos.h,
}}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import { Stepper } from '../Stepper';
import { Step0, type ValidTeamType } from './Step0';
import { Step1, type ValidTeamSize } from './Step1';
import { Step0, ValidTeamType } from './Step0';
import { Step1, ValidTeamSize } from './Step1';
import { Step2 } from './Step2';
import { httpGet } from '../../lib/http';
import { getUrlParams, setUrlParams } from '../../lib/browser';

View File

@@ -21,7 +21,7 @@ export function NextButton(props: NextButtonProps) {
return (
<button
type={type as any}
type={type}
onClick={onClick}
disabled={isLoading}
className={

View File

@@ -39,7 +39,7 @@ export function NotDropdown(props: NotDropdownProps) {
<img
alt={singularName}
src={ChevronDownIcon.src}
src={ChevronDownIcon}
className={'relative top-[1px] h-[17px] w-[17px] opacity-40'}
/>
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import { httpGet, httpPut } from '../../lib/http';
import type { PageType } from '../CommandMenu/CommandMenu';
import ChevronDownIcon from '../../icons/chevron-down.svg';

View File

@@ -1,5 +1,5 @@
import { ChevronDownIcon } from '../ReactIcons/ChevronDownIcon';
import { useRef, useState } from 'react';
import { useRef, useState } from 'preact/hooks';
import { useOutsideClick } from '../../hooks/use-outside-click';
const allowedRoles = [

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'preact/hooks';
import { useKeydown } from '../../hooks/use-keydown';
import { useOutsideClick } from '../../hooks/use-outside-click';
import type { PageType } from '../CommandMenu/CommandMenu';
@@ -68,19 +68,19 @@ export function SelectRoadmapModal(props: SelectRoadmapModalProps) {
);
return (
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
<div class="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div class="relative mx-auto h-full w-full max-w-2xl p-4 md:h-auto">
<div
ref={popupBodyEl}
className="popup-body relative mt-4 overflow-hidden rounded-lg bg-white shadow"
class="popup-body relative mt-4 overflow-hidden rounded-lg bg-white shadow"
>
<button
type="button"
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}
>
<img alt={'close'} src={CloseIcon.src} className="h-4 w-4" />
<span className="sr-only">Close modal</span>
<img alt={'close'} src={CloseIcon} className="h-4 w-4" />
<span class="sr-only">Close modal</span>
</button>
<input
ref={searchInputEl}

View File

@@ -2,20 +2,20 @@ 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 { useState } from 'preact/hooks';
import { NextButton } from './NextButton';
export const validTeamTypes = [
{
value: 'company',
label: 'Company',
icon: BuildingIcon.src,
icon: BuildingIcon,
description: 'Track the skills and learning progress of the tech team at your company',
},
{
value: 'study_group',
label: 'Study Group',
icon: UsersIcon.src,
icon: UsersIcon,
description: 'Invite your friends or course-mates and track your learning progress together',
},
] as const;

View File

@@ -1,5 +1,5 @@
import { type FormEvent, useEffect, useRef, useState } from 'react';
import { type AppError, httpPost, httpPut } from '../../lib/http';
import { useEffect, useRef, useState } from 'preact/hooks';
import { AppError, httpPost, httpPut } from '../../lib/http';
import type { ValidTeamType } from './Step0';
import type { TeamDocument } from './CreateTeamForm';
import { NextButton } from './NextButton';
@@ -49,7 +49,7 @@ export function Step1(props: Step1Props) {
team?.teamSize || ('' as any)
);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
if (!name || !selectedTeamType) {
@@ -124,7 +124,7 @@ export function Step1(props: Step1Props) {
<form onSubmit={handleSubmit}>
<div className="flex w-full flex-col">
<label
htmlFor="name"
for="name"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
{selectedTeamType === 'company' ? 'Company Name' : 'Group Name'}
@@ -133,7 +133,7 @@ export function Step1(props: Step1Props) {
type="text"
name="name"
ref={nameRef as any}
autoFocus={true}
autofocus={true}
id="name"
className="mt-2 block w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm outline-none placeholder:text-gray-400 focus:ring-2 focus:ring-black focus:ring-offset-1"
placeholder="Roadmap Inc."
@@ -147,7 +147,7 @@ export function Step1(props: Step1Props) {
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label
htmlFor="website"
for="website"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Website
@@ -168,7 +168,7 @@ export function Step1(props: Step1Props) {
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label htmlFor="website" className="text-sm leading-none text-slate-500">
<label for="website" className="text-sm leading-none text-slate-500">
Company LinkedIn URL
</label>
<input
@@ -187,7 +187,7 @@ export function Step1(props: Step1Props) {
)}
<div className="mt-4 flex w-full flex-col">
<label htmlFor="website" className="text-sm leading-none text-slate-500">
<label for="website" className="text-sm leading-none text-slate-500">
GitHub Organization URL
</label>
<input
@@ -205,7 +205,7 @@ export function Step1(props: Step1Props) {
{selectedTeamType === 'company' && (
<div className="mt-4 flex w-full flex-col">
<label
htmlFor="team-size"
for="team-size"
className='text-sm leading-none text-slate-500 after:text-red-400 after:content-["*"]'
>
Tech Team Size
@@ -237,7 +237,7 @@ export function Step1(props: Step1Props) {
</div>
)}
<div className="mt-4 flex flex-col items-center justify-between gap-2 md:flex-row">
<div className="mt-4 flex flex-col md:flex-row items-center justify-between gap-2">
<button
type="button"
onClick={onBack}

View File

@@ -1,4 +1,4 @@
import { RoadmapSelector, type TeamResourceConfig } from './RoadmapSelector';
import { RoadmapSelector, TeamResourceConfig } from './RoadmapSelector';
import type { TeamDocument } from './CreateTeamForm';
type Step2Props = {

View File

@@ -1,8 +1,8 @@
import type { TeamDocument } from './CreateTeamForm';
import { NextButton } from './NextButton';
import { TrashIcon } from '../ReactIcons/TrashIcon';
import { type AllowedRoles, RoleDropdown } from './RoleDropdown';
import { useEffect, useRef, useState } from 'react';
import { AllowedRoles, RoleDropdown } from './RoleDropdown';
import { useEffect, useRef, useState } from 'preact/hooks';
import { httpPost } from '../../lib/http';
type Step3Props = {
@@ -75,9 +75,9 @@ export function Step3(props: Step3Props) {
return (
<form className="mt-4 flex w-full flex-col" onSubmit={onSubmit}>
<div className="mb-1 mt-2">
<h2 className="mb-1 md:mb-2 text-lg md:text-2xl font-bold">Invite your Team</h2>
<p className="text-sm text-gray-700">
<div class="mb-1 mt-2">
<h2 class="mb-1 md:mb-2 text-lg md:text-2xl font-bold">Invite your Team</h2>
<p class="text-sm text-gray-700">
Use the form below to invite your team members to your team. You can
also invite them later.
</p>
@@ -88,7 +88,7 @@ export function Step3(props: Step3Props) {
<div className="flex flex-col sm:flex-row gap-2" key={user.id}>
<input
ref={userCounter === users.length - 1 ? emailInputRef : null}
autoFocus={true}
autofocus={true}
type="email"
name="email"
required

View File

@@ -8,15 +8,15 @@ type Step4Props = {
export function Step4({ team }: Step4Props) {
return (
<div className="mt-4 flex flex-col rounded-xl border py-12 text-center">
<div className="mb-1 flex flex-col items-center">
<div class="mb-1 flex flex-col items-center">
<CheckIcon additionalClasses={'h-14 w-14 mb-4 opacity-100'} />
<h2 className="mb-2 text-2xl font-bold">Team Created</h2>
<p className="text-sm text-gray-700">
<h2 class="mb-2 text-2xl font-bold">Team Created</h2>
<p class="text-sm text-gray-700">
Your team has been created. Happy learning!
</p>
<a
href={`/team/progress?t=${team._id}`}
className="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
class="mt-4 rounded-md bg-black px-5 py-1.5 text-sm text-white"
>
View Team
</a>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'preact/hooks';
import { wireframeJSONToSVG } from 'roadmap-renderer';
import { Spinner } from '../ReactIcons/Spinner';
import { httpGet, httpPut } from '../../lib/http';
@@ -8,7 +8,7 @@ import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import type { TeamResourceConfig } from './RoadmapSelector';
import { useToast } from '../../hooks/use-toast';
import { useStore } from '@nanostores/react';
import { useStore } from '@nanostores/preact';
import { $currentTeam } from '../../stores/team';
export type ProgressMapProps = {
@@ -148,8 +148,8 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
}, []);
return (
<div className="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div className="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
<div class="fixed left-0 right-0 top-0 z-50 h-full items-center justify-center overflow-y-auto overflow-x-hidden overscroll-contain bg-black/50">
<div class="relative mx-auto h-full w-full max-w-4xl p-4 md:h-auto">
<div
id={
currentTeam?.type === 'company'
@@ -157,7 +157,7 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
: 'original-roadmap'
}
ref={popupBodyEl}
className="popup-body relative rounded-lg bg-white shadow"
class="popup-body relative rounded-lg bg-white shadow"
>
<div
className={
@@ -201,7 +201,7 @@ export function UpdateTeamResourceModal(props: ProgressMapProps) {
<div id="resource-svg-wrap" ref={containerEl} className="px-4"></div>
{isLoading && (
<div className="flex w-full justify-center">
<div class="flex w-full justify-center">
<Spinner
isDualRing={false}
className="mb-4 mt-2 h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-8 sm:w-8"

View File

@@ -1,4 +1,4 @@
import { type FormEvent, useEffect, useState } from 'react';
import {useEffect, useState} from 'preact/hooks';
import { httpDelete } from '../../lib/http';
import { logout } from '../Navigation/navigation';
@@ -10,9 +10,9 @@ export function DeleteAccountForm() {
useEffect(() => {
setError('');
setConfirmationText('');
}, []);
}, [])
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
@@ -53,7 +53,7 @@ export function DeleteAccountForm() {
type="text"
name="delete-account"
id="delete-account"
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
className="mt-2 block w-full rounded-md border border-gray-300 py-2 px-3 outline-none placeholder:text-gray-400 focus:border-gray-400"
placeholder={'Type "delete" to confirm'}
required
autoFocus

View File

@@ -12,6 +12,6 @@ import { DeleteAccountForm } from './DeleteAccountForm';
<p class="text-black font-medium -mb-2 mt-3 text-base">Please type "delete" to confirm.</p>
<DeleteAccountForm client:only="react" />
<DeleteAccountForm client:only />
</div>
</Popup>

View File

@@ -1,4 +1,4 @@
import { type FormEvent, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'preact/hooks';
import { httpDelete } from '../lib/http';
import type { TeamDocument } from './CreateTeam/CreateTeamForm';
import { useTeamId } from '../hooks/use-team-id';
@@ -34,7 +34,7 @@ export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
inputEl.current?.focus();
}, []);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
const handleSubmit = async (e: Event) => {
e.preventDefault();
setIsLoading(true);
setError('');
@@ -69,18 +69,18 @@ export function DeleteTeamPopup(props: DeleteTeamPopupProps) {
return (
<>
<div className="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
<div class="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div class="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyEl}
className="popup-body relative rounded-lg bg-white p-4 shadow"
class="popup-body relative rounded-lg bg-white p-4 shadow"
>
<h2 className="text-2xl font-semibold text-black">Delete Team</h2>
<h2 class="text-2xl font-semibold text-black">Delete Team</h2>
<p className="text-gray-500">
This will permanently delete your team and all associated data.
</p>
<p className="-mb-2 mt-3 text-base font-medium text-black">
<p class="-mb-2 mt-3 text-base font-medium text-black">
Please type "delete" to confirm.
</p>
<form onSubmit={handleSubmit}>

View File

@@ -22,7 +22,7 @@ if (faqs.length === 0) {
<div class='border-t bg-gray-100 mt-8'>
<div class='container'>
<div class='flex justify-between relative -top-5'>
<h2 class='text-sm sm:text-base font-medium py-1 px-3 border bg-white rounded-md'>Frequently Asked Questions</h2>
<h1 class='text-sm sm:text-base font-medium py-1 px-3 border bg-white rounded-md'>Frequently Asked Questions</h1>
</div>
<div class='flex flex-col gap-1 pb-14'>

View File

@@ -16,8 +16,8 @@ export function FavoriteIcon(props: FavoriteIconProps) {
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5ZM5.93682 1.25C6.42732 1.25 6.83382 1.632 6.86382 2.122L7.24932 8.506C7.25216 8.55018 7.24229 8.59425 7.22089 8.63301C7.19949 8.67176 7.16745 8.70359 7.12854 8.72472C7.08964 8.74585 7.0455 8.75542 7.00134 8.75228C6.95718 8.74914 6.91484 8.73343 6.87932 8.707L4.27582 6.783C4.19591 6.72397 4.09917 6.69211 3.99982 6.69211C3.90047 6.69211 3.80373 6.72397 3.72382 6.783L1.11982 8.707C1.0843 8.73343 1.04196 8.74914 0.9978 8.75228C0.953639 8.75542 0.909502 8.74585 0.8706 8.72472C0.831697 8.70359 0.799653 8.67176 0.778252 8.63301C0.756851 8.59425 0.746986 8.55018 0.749822 8.506L1.13632 2.122C1.16632 1.632 1.57232 1.25 2.06282 1.25H5.93682Z"
fill="currentColor"
/>
@@ -35,8 +35,8 @@ export function FavoriteIcon(props: FavoriteIconProps) {
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.93682 0.5H2.06282C1.63546 0.500094 1.22423 0.663195 0.912987 0.956045C0.601741 1.2489 0.413919 1.64944 0.387822 2.076L0.00182198 8.461C-0.012178 8.6905 0.0548218 8.9185 0.191822 9.104L0.242322 9.1665C0.575322 9.5485 1.15132 9.6165 1.56582 9.31L3.99982 7.5115L6.43382 9.31C6.58413 9.42115 6.76305 9.48708 6.94954 9.50006C7.13603 9.51303 7.32235 9.4725 7.4866 9.38323C7.65085 9.29397 7.78621 9.15967 7.87677 8.99613C7.96733 8.83258 8.00932 8.64659 7.99782 8.46L7.61232 2.0765C7.58622 1.64981 7.39835 1.24914 7.08701 0.956192C6.77567 0.663248 6.36431 0.500094 5.93682 0.5Z"
fill="currentColor"
/>

View File

@@ -6,18 +6,11 @@ export interface FeaturedItemType {
isNew?: boolean;
url: string;
text: string;
allowBookmark?: boolean;
}
export interface Props extends FeaturedItemType {}
const {
isUpcoming = false,
isNew = false,
text,
url,
allowBookmark = true,
} = Astro.props;
const { isUpcoming = false, isNew = false, text, url } = Astro.props;
---
<a
@@ -33,17 +26,11 @@ const {
{text}
</span>
{
allowBookmark && (
<MarkFavorite
resourceId={url.split('/').pop()!}
resourceType={
url.includes('best-practices') ? 'best-practice' : 'roadmap'
}
client:only='react'
/>
)
}
<MarkFavorite
resourceId={url.split('/').pop()!}
resourceType={url.includes('best-practices') ? 'best-practice' : 'roadmap'}
client:load
/>
{
isNew && (

View File

@@ -4,16 +4,15 @@ import FeaturedItem, { FeaturedItemType } from './FeaturedItem.astro';
export interface Props {
featuredItems: FeaturedItemType[];
heading: string;
allowBookmark?: boolean;
}
const { featuredItems, heading, allowBookmark = true } = Astro.props;
const { featuredItems, heading } = Astro.props;
---
<div class='relative border-b border-b-[#1e293c] py-10 sm:py-14'>
<div class='container'>
<h2
class='text-md font-regular absolute -top-[17px] flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 sm:left-1/2 sm:-translate-x-1/2'
class='text-md font-regular absolute flex rounded-lg border border-[#1e293c] bg-slate-900 px-3 py-1 text-slate-400 -top-[17px] sm:left-1/2 sm:-translate-x-1/2'
>
{heading}
</h2>
@@ -23,7 +22,6 @@ const { featuredItems, heading, allowBookmark = true } = Astro.props;
featuredItems.map((featuredItem) => (
<li>
<FeaturedItem
allowBookmark={allowBookmark}
text={featuredItem.text}
url={featuredItem.url}
isNew={featuredItem.isNew}

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import type { MouseEvent } from "react";
import { useEffect, useState } from 'preact/hooks';
import { httpPatch } from '../../lib/http';
import type { ResourceType } from '../../lib/resource-progress';
import { isLoggedIn } from '../../lib/jwt';
@@ -21,16 +20,15 @@ export function MarkFavorite({
favorite,
className,
}: MarkFavoriteType) {
const isAuthenticated = isLoggedIn();
const localStorageKey = `${resourceType}-${resourceId}-favorite`;
const toast = useToast();
const [isLoading, setIsLoading] = useState(false);
const [isFavorite, setIsFavorite] = useState(
isAuthenticated ? (favorite ?? localStorage.getItem(localStorageKey) === '1') : false
favorite ?? localStorage.getItem(localStorageKey) === '1'
);
async function toggleFavoriteHandler(e: MouseEvent<HTMLButtonElement>) {
async function toggleFavoriteHandler(e: Event) {
e.preventDefault();
if (!isLoggedIn()) {
showLoginPopup();
@@ -96,7 +94,6 @@ export function MarkFavorite({
return (
<button
aria-label={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
onClick={toggleFavoriteHandler}
tabIndex={-1}
className={`${isFavorite ? '' : 'opacity-30 hover:opacity-100'} ${

View File

@@ -1,146 +0,0 @@
import { type FormEvent, useEffect, useRef, useState } from 'react';
import { useToast } from '../../hooks/use-toast';
import { useTeamId } from '../../hooks/use-team-id';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { useKeydown } from '../../hooks/use-keydown';
import { httpPost } from '../../lib/http';
import { CheckIcon } from '../ReactIcons/CheckIcon';
type SubmitFeedbackPopupProps = {
onClose: () => void;
};
export function SubmitFeedbackPopup(props: SubmitFeedbackPopupProps) {
const { onClose } = props;
const popupBodyEl = useRef<HTMLDivElement>(null);
const inputEl = useRef<HTMLTextAreaElement>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [feedbackText, setFeedbackText] = useState('');
const [isSuccess, setIsSuccess] = useState(false);
const { teamId } = useTeamId();
useOutsideClick(popupBodyEl, () => {
onClose();
});
useKeydown('Escape', () => {
onClose();
});
useEffect(() => {
inputEl.current?.focus();
}, []);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError('');
const { response, error } = await httpPost<{ status: 'ok' }>(
`${import.meta.env.PUBLIC_API_URL}/v1-submit-team-feedback/${teamId}`,
{
feedback: feedbackText,
}
);
if (error || !response) {
setIsLoading(false);
setError(error?.message || 'Something went wrong');
return;
}
setIsSuccess(true);
};
const handleClosePopup = () => {
setIsLoading(false);
setError('');
setFeedbackText('');
onClose();
};
return (
<div className="fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyEl}
className="popup-body relative rounded-lg bg-white p-4 shadow"
>
{!isSuccess && (
<>
<h2 className="mb-1 text-xl font-semibold text-black">
Enter your feedback
</h2>
<p className={'text-sm text-gray-500'}>
Help us improve your experience.
</p>
<form onSubmit={handleSubmit}>
<div className="my-4">
<textarea
ref={inputEl}
name="submit-feedback"
id="submit-feedback"
className="mt-2 block min-h-[150px] w-full resize-none rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400"
placeholder="Enter your feedback"
required
autoFocus
value={feedbackText}
onInput={(e) =>
setFeedbackText((e.target as HTMLInputElement).value)
}
/>
{error && (
<p className="mt-2 rounded-md bg-red-100 p-2 text-red-700">
{error}
</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={isLoading}
onClick={handleClosePopup}
className="flex-grow cursor-pointer rounded-md bg-gray-200 py-2 text-center"
>
Cancel
</button>
<button
disabled={isLoading}
type="submit"
className="flex-grow cursor-pointer rounded-md bg-black py-2 text-white disabled:opacity-40"
>
{isLoading ? 'Please wait ..' : 'Send'}
</button>
</div>
</form>
</>
)}
{isSuccess && (
<div className="flex w-full flex-col items-center">
<CheckIcon additionalClasses="w-14 h-14 text-green-500 mt-4" />
<h1 className="mt-4 text-xl font-semibold text-black sm:text-2xl">
Feedback Submitted
</h1>
<p className="text-center text-sm text-gray-500 sm:text-base">
Thank you for submitting your feedback.
</p>
<button
type="button"
onClick={handleClosePopup}
className="mt-4 w-full flex-grow cursor-pointer rounded-lg bg-black py-2 text-center text-white disabled:opacity-40"
>
Close
</button>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -31,7 +31,8 @@ import Icon from './AstroIcon.astro';
<a
class='px-2 py-1.5 transition-colors hover:text-white sm:border-b-0 sm:px-0 sm:py-0'
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'>YouTube</a>
target='_blank'>YouTube</a
>
</p>
<div class='flex flex-col justify-between gap-12 sm:flex-row'>
@@ -67,7 +68,6 @@ import Icon from './AstroIcon.astro';
<a href='/privacy' class='hover:text-white'>Privacy</a>
<span class='mx-1.5'>&middot;</span>
<a
aria-label="Subscribe to YouTube channel"
href='https://youtube.com/theroadmap?sub_confirmation=1'
target='_blank'
class='hover:text-white'
@@ -75,7 +75,6 @@ import Icon from './AstroIcon.astro';
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
</a>
<a
aria-label="Follow on Twitter"
href='https://twitter.com/roadmapsh'
target='_blank'
class='ml-2 hover:text-white'

View File

@@ -28,9 +28,6 @@ svg .clickable-group:hover > [fill='rgb(255,255,0)'] {
svg .clickable-group:hover > [fill='rgb(255,229,153)'] {
fill: #f3c950;
}
svg .clickable-group:hover > [stroke='rgb(255,229,153)'] {
stroke: #f3c950;
}
svg .clickable-group:hover > [fill='rgb(153,153,153)'] {
fill: #646464;
@@ -52,10 +49,6 @@ svg .done rect {
fill: #cbcbcb !important;
}
svg .done rect[stroke="rgb(255,229,153)"] {
stroke: #cbcbcb !important;
}
svg .done text,
svg .skipped text {
text-decoration: line-through;
@@ -80,7 +73,7 @@ svg .learning text {
svg .clickable-group.done[data-group-id^='check:'] rect {
fill: gray !important;
stroke: gray !important;
stroke: gray;
}
.clickable-group rect {
@@ -136,7 +129,7 @@ svg .removed path {
#customized-roadmap #resource-svg-wrap g:not([class]),
#customized-roadmap #resource-svg-wrap circle,
#customized-roadmap #resource-svg-wrap path[stroke='#fff'],
#customized-roadmap #resource-svg-wrap g[data-group-id$='-note'] {
#customized-roadmap #resource-svg-wrap g[data-group-id$="-note"]{
display: none;
}

View File

@@ -5,9 +5,10 @@ import {
refreshProgressCounters,
renderResourceProgress,
renderTopicProgress,
ResourceProgressType,
ResourceType,
updateResourceProgress,
} from '../../lib/resource-progress';
import type { ResourceProgressType, ResourceType } from '../../lib/resource-progress';
import { pageProgressMessage } from '../../stores/page';
import { showLoginPopup } from '../../lib/popup';

View File

@@ -1,54 +0,0 @@
import UserPlusIcon from '../../icons/user-plus.svg';
import CopyIcon from '../../icons/copy.svg';
import { useCopyText } from '../../hooks/use-copy-text';
type EmptyFriendsProps = {
befriendUrl: string;
};
export function EmptyFriends(props: EmptyFriendsProps) {
const { befriendUrl } = props;
const { isCopied, copyText } = useCopyText();
return (
<div className="rounded-md">
<div className="mx-auto flex flex-col items-center p-7 text-center">
<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.
</p>
<div className="flex w-full max-w-[352px] items-center justify-center gap-2 rounded-lg border-2 p-1 text-sm">
<input
onClick={(e) => {
e.currentTarget.select();
copyText(befriendUrl);
}}
type="text"
value={befriendUrl}
className="w-full border-none bg-transparent px-1.5 outline-none"
readOnly
/>
<button
className={`flex items-center justify-center gap-1 rounded-md border-0 p-2 px-4 text-sm text-black ${
isCopied
? 'bg-green-300 hover:bg-green-300'
: 'bg-gray-200 hover:bg-gray-300'
}`}
onClick={() => {
copyText(befriendUrl);
}}
>
<img src={CopyIcon.src} className="h-4 w-4" alt="Invite Friends" />
{isCopied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,326 +0,0 @@
import { useState } from 'react';
import type { ListFriendsResponse } from './FriendsPage';
import { DeleteUserIcon } from '../ReactIcons/DeleteUserIcon';
import { pageProgressMessage } from '../../stores/page';
import { httpDelete, httpPost } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { TrashIcon } from '../ReactIcons/TrashIcon';
import { AddedUserIcon } from '../ReactIcons/AddedUserIcon';
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
type FriendProgressItemProps = {
friend: ListFriendsResponse[0];
onShowResourceProgress: (resourceId: string) => void;
onReload: () => void;
};
export function FriendProgressItem(props: FriendProgressItemProps) {
const { friend, onShowResourceProgress, onReload } = props;
const toast = useToast();
const [isConfirming, setIsConfirming] =
useState<ListFriendsResponse[0]['status']>();
async function deleteFriend(userId: string, successMessage: string) {
pageProgressMessage.set('Please wait...');
const { response, error } = await httpDelete(
`${import.meta.env.PUBLIC_API_URL}/v1-delete-friend/${userId}`,
{}
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success(successMessage);
onReload();
}
async function addFriend(userId: string, successMessage: string) {
pageProgressMessage.set('Please wait...');
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-add-friend/${userId}`,
{}
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
toast.success(successMessage);
onReload();
}
const roadmaps = (friend.roadmaps || []).sort((a, b) => {
return b.done - a.done;
});
const [showAll, setShowAll] = useState(false);
const status = friend.status;
return (
<>
<div
className={`flex h-full min-h-[270px] flex-col overflow-hidden rounded-md border`}
key={friend.userId}
>
<div className={`relative flex items-center gap-3 border-b p-3`}>
<img
src={
friend.avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${friend.avatar}`
: '/images/default-avatar.png'
}
alt={friend.name || ''}
className="h-8 w-8 rounded-full"
/>
<div className="inline-grid w-full">
<h3 className="truncate font-medium">{friend.name}</h3>
<p className="truncate text-sm text-gray-500">{friend.email}</p>
</div>
</div>
{friend.status === 'accepted' && (
<>
<div className="relative flex grow flex-col space-y-2 p-3">
{(showAll ? roadmaps : roadmaps.slice(0, 4)).map((progress) => {
return (
<button
onClick={() => onShowResourceProgress(progress.resourceId)}
className="group relative overflow-hidden rounded-md border p-2 hover:border-gray-300 hover:text-black focus:outline-none"
key={progress.resourceId}
>
<span className="relative z-10 flex items-center justify-between text-sm">
<span className="inline-grid">
<span className={'truncate'}>{progress.title}</span>
</span>
<span className="ml-1.5 shrink-0 text-xs text-gray-400">
{progress.done} / {progress.total}
</span>
</span>
<span
className="absolute inset-0 bg-gray-100 group-hover:bg-gray-200"
style={{
width: `${(progress.done / progress.total) * 100}%`,
}}
/>
</button>
);
})}
{roadmaps.length > 4 && !showAll && (
<button
onClick={() => setShowAll(true)}
className={'text-xs text-gray-400 underline'}
>
+ {roadmaps.length - 4} more
</button>
)}
{showAll && (
<button
onClick={() => setShowAll(false)}
className={'text-sm text-gray-400 underline'}
>
- Show less
</button>
)}
{roadmaps.length === 0 && (
<div className="text-sm text-gray-500">No progress</div>
)}
</div>
<>
{isConfirming !== 'accepted' && (
<button
className="flex w-full items-center justify-center border-t py-2 text-sm font-medium text-red-700 hover:bg-red-50/50 hover:text-red-500"
onClick={() => {
setIsConfirming('accepted');
}}
>
<TrashIcon className="mr-1 h-4 w-4" />
Remove Friend
</button>
)}
{isConfirming === 'accepted' && (
<span className="flex w-full items-center justify-center border-t py-2 text-sm text-red-700">
Are you sure?{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
deleteFriend(friend.userId, 'Friend removed').finally(
() => {
pageProgressMessage.set('');
}
);
}}
>
Yes
</button>{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
setIsConfirming(undefined);
}}
>
No
</button>
</span>
)}
</>
</>
)}
{friend.status === 'rejected' && (
<>
<div
className={'flex w-full flex-grow items-center justify-center'}
>
<span className=" flex flex-col items-center text-red-500">
<DeleteUserIcon additionalClasses="mr-2 h-8 w-8 mb-1" />
Request Rejected
</span>
</div>
<span className="flex cursor-default items-center justify-center border-t py-2 text-center text-sm">
Changed your mind?{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
addFriend(friend.userId, 'Friend request accepted').finally(
() => {
pageProgressMessage.set('');
}
);
}}
>
Accept
</button>
</span>
</>
)}
{friend.status === 'got_rejected' && (
<>
<div
className={'flex w-full flex-grow items-center justify-center'}
>
<span className=" flex flex-col items-center text-sm text-red-500">
<DeleteUserIcon additionalClasses="mr-2 h-8 w-8 mb-1" />
Request Rejected
</span>
</div>
<span className="flex cursor-default items-center justify-center border-t py-2.5 text-center text-sm">
<button
className="ml-2 flex items-center font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
deleteFriend(friend.userId, 'Friend request removed').finally(
() => {
pageProgressMessage.set('');
}
);
}}
>
<TrashIcon className="mr-1 h-4 w-4" />
Delete Request
</button>
</span>
</>
)}
{friend.status === 'sent' && (
<>
<div
className={'flex w-full flex-grow items-center justify-center'}
>
<span className=" flex flex-col items-center text-green-500">
<AddedUserIcon additionalClasses="mr-2 h-8 w-8 mb-1" />
Request Sent
</span>
</div>
<>
{isConfirming !== 'sent' && (
<button
className="flex w-full items-center justify-center border-t py-2 text-sm font-medium text-red-700 hover:bg-red-50/50 hover:text-red-500"
onClick={() => {
setIsConfirming('sent');
}}
>
<TrashIcon className="mr-1 h-4 w-4" />
Withdraw Request
</button>
)}
{isConfirming === 'sent' && (
<span className="flex w-full items-center justify-center border-t py-2 text-sm text-red-700">
Are you sure?{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
deleteFriend(
friend.userId,
'Friend request withdrawn'
).finally(() => {
pageProgressMessage.set('');
});
}}
>
Yes
</button>{' '}
<button
className="ml-2 font-medium text-red-700 underline underline-offset-2 hover:text-red-500"
onClick={() => {
setIsConfirming(undefined);
}}
>
No
</button>
</span>
)}
</>
</>
)}
{friend.status === 'received' && (
<>
<div
className={
'flex w-full flex-grow flex-col items-center justify-center px-4'
}
>
<AddUserIcon additionalClasses="mr-2 h-8 w-8 mb-1 text-green-500" />
<span className="mb-3 text-green-600">Request Received</span>
<button
onClick={() => {
addFriend(friend.userId, 'Friend request accepted').finally(
() => {
pageProgressMessage.set('');
}
);
}}
className="mb-1 block w-full max-w-[150px] rounded-md bg-black py-1.5 text-sm text-white"
>
Accept
</button>
<button
onClick={() => {
deleteFriend(
friend.userId,
'Friend request rejected'
).finally(() => {
pageProgressMessage.set('');
});
}}
className="block w-full max-w-[150px] rounded-md border border-red-500 py-1 text-sm text-red-500"
>
Reject
</button>
</div>
</>
)}
</div>
</>
);
}

View File

@@ -1,216 +0,0 @@
import { useEffect, useState } from 'react';
import { pageProgressMessage } from '../../stores/page';
import { useAuth } from '../../hooks/use-auth';
import { AddUserIcon } from '../ReactIcons/AddUserIcon';
import { httpGet } from '../../lib/http';
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';
type FriendResourceProgress = {
updatedAt: string;
title: string;
resourceId: string;
resourceType: string;
learning: number;
skipped: number;
done: number;
total: number;
};
export type ListFriendsResponse = {
userId: string;
name: string;
email: string;
avatar: string;
status: FriendshipStatus;
roadmaps: FriendResourceProgress[];
bestPractices: FriendResourceProgress[];
}[];
type GroupingType = {
label: string;
value: 'active' | 'requests' | 'sent';
statuses: FriendshipStatus[];
};
const groupingTypes: GroupingType[] = [
{ label: 'Active', value: 'active', statuses: ['accepted'] },
{ label: 'Requests', value: 'requests', statuses: ['received', 'rejected'] },
{ label: 'Sent', value: 'sent', statuses: ['sent', 'got_rejected'] },
];
export function FriendsPage() {
const toast = useToast();
const [showInviteFriendPopup, setShowInviteFriendPopup] = useState(false);
const [showFriendProgress, setShowFriendProgress] = useState<{
resourceId: string;
friend: ListFriendsResponse[0];
}>();
const [isLoading, setIsLoading] = useState(true);
const [friends, setFriends] = useState<ListFriendsResponse>([]);
const [selectedGrouping, setSelectedGrouping] =
useState<GroupingType['value']>('active');
async function loadFriends() {
const { response, error } = await httpGet<ListFriendsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-friends`
);
if (error || !response) {
toast.error(error?.message || 'Something went wrong');
return;
}
setFriends(response);
}
useEffect(() => {
loadFriends().finally(() => {
pageProgressMessage.set('');
setIsLoading(false);
});
}, []);
const user = useAuth();
const baseUrl = import.meta.env.DEV
? 'http://localhost:3000'
: 'https://roadmap.sh';
const befriendUrl = `${baseUrl}/befriend?u=${user?.id}`;
const selectedGroupingType = groupingTypes.find(
(grouping) => grouping.value === selectedGrouping
);
const filteredFriends = friends.filter((friend) =>
selectedGroupingType?.statuses.includes(friend.status)
);
const receivedRequests = friends.filter(
(friend) => friend.status === 'received'
);
if (isLoading) {
return null;
}
if (!friends?.length) {
return <EmptyFriends befriendUrl={befriendUrl} />;
}
return (
<div>
{showInviteFriendPopup && (
<InviteFriendPopup
befriendUrl={befriendUrl}
onClose={() => setShowInviteFriendPopup(false)}
/>
)}
{showFriendProgress && (
<UserProgressModal
userId={showFriendProgress.friend.userId}
resourceId={showFriendProgress.resourceId}
resourceType={'roadmap'}
onClose={() => setShowFriendProgress(undefined)}
/>
)}
<div className="mb-4 flex flex-col items-stretch justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
<div className="flex items-center gap-2">
{groupingTypes.map((grouping) => {
let requestCount = 0;
if (grouping.value === 'requests') {
requestCount = receivedRequests.length;
}
return (
<button
key={grouping.value}
className={`relative flex items-center justify-center rounded-md border p-1 px-3 text-sm ${
selectedGrouping === grouping.value
? ' border-gray-400 bg-gray-200 '
: ''
} w-full sm:w-auto`}
onClick={() => setSelectedGrouping(grouping.value)}
>
{grouping.label}
{requestCount > 0 && (
<span className="ml-1.5 inline-flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] text-white">
{requestCount}
</span>
)}
</button>
);
})}
</div>
<button
onClick={() => {
setShowInviteFriendPopup(true);
}}
className="flex items-center justify-center gap-1.5 rounded-md border border-gray-400 bg-gray-50 p-1 px-2 text-sm hover:border-gray-500 hover:bg-gray-100"
>
<AddUserIcon additionalClasses="w-4 h-4" />
Invite Friends
</button>
</div>
{filteredFriends.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{filteredFriends.map((friend) => (
<FriendProgressItem
friend={friend}
onShowResourceProgress={(resourceId) => {
setShowFriendProgress({
resourceId,
friend,
});
}}
key={friend.userId}
onReload={() => {
pageProgressMessage.set('Reloading friends ..');
loadFriends().finally(() => {
pageProgressMessage.set('');
});
}}
/>
))}
</div>
)}
{filteredFriends.length === 0 && (
<div className="flex flex-col items-center justify-center py-12">
<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'}
{selectedGrouping === 'requests' && 'No requests received'}
</h2>
<p className="text-sm text-gray-500">
Invite your friends to join you on Roadmap
</p>
<button
onClick={() => {
setShowInviteFriendPopup(true);
}}
className="mt-4 flex items-center justify-center gap-1.5 rounded-md border border-gray-400 bg-gray-50 p-1 px-2 text-sm hover:border-gray-500 hover:bg-gray-100"
>
<AddUserIcon additionalClasses="w-4 h-4" />
Invite Friends
</button>
</div>
)}
</div>
);
}

View File

@@ -1,69 +0,0 @@
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';
type InviteFriendPopupProps = {
befriendUrl: string;
onClose: () => void;
};
export function InviteFriendPopup(props: InviteFriendPopupProps) {
const { onClose, befriendUrl } = props;
const { isCopied, copyText } = useCopyText();
const popupBodyRef = useRef<HTMLDivElement>(null);
const handleClosePopup = () => {
onClose();
};
useOutsideClick(popupBodyRef, handleClosePopup);
return (
<div className="popup fixed left-0 right-0 top-0 z-50 flex h-full items-center justify-center overflow-y-auto overflow-x-hidden bg-black/50">
<div className="relative h-full w-full max-w-md p-4 md:h-auto">
<div
ref={popupBodyRef}
className="popup-body relative rounded-lg bg-white p-4 shadow"
>
<h3 className="mb-1.5 text-xl font-medium sm:text-2xl">Invite URL</h3>
<p className="mb-3 hidden text-sm leading-none text-gray-400 sm:block">
Share the link below with your friends to invite them.
</p>
<div className="mt-4 flex flex-col gap-2 sm:mt-4">
<input
readOnly={true}
className="mt-2 block w-full rounded-md border border-gray-300 px-3 py-2 outline-none placeholder:text-gray-400 focus:border-gray-400"
value={befriendUrl}
onClick={(e: MouseEvent<HTMLInputElement>) => {
(e?.target as HTMLInputElement)?.select();
copyText(befriendUrl);
}}
/>
<button
className={`flex items-center justify-center gap-1 rounded-md border-0 px-3 py-2.5 text-sm text-black ${
isCopied
? 'bg-green-300 hover:bg-green-300'
: 'bg-gray-200 hover:bg-gray-300'
}`}
onClick={() => {
copyText(befriendUrl);
}}
>
<img
src={CopyIcon.src}
className="h-4 w-4"
alt="Invite Friends"
/>
{isCopied ? 'Copied' : 'Copy URL'}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,45 +0,0 @@
import { httpGet } from '../../lib/http';
import { useEffect, useState } from 'react';
type GetFriendCountsResponse = {
sentCount: number;
acceptedCount: number;
receivedCount: number;
rejectedCount: number;
gotRejectedCount: number;
};
export function SidebarFriendsCounter() {
const [friendCounts, setFriendCounts] = useState<GetFriendCountsResponse>();
async function getFriendCounts() {
const { response, error } = await httpGet<GetFriendCountsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-get-friend-counts`
);
if (error || !response) {
return;
}
setFriendCounts(response);
}
useEffect(() => {
getFriendCounts().finally(() => null);
}, []);
const pendingCount = friendCounts?.receivedCount || 0;
if (!pendingCount) {
return (
<span className="relative mr-1 flex items-center">
<span className="relative rounded-full bg-gray-200 p-1 text-xs" />
<span className="absolute bottom-0 left-0 right-0 top-0 animate-ping rounded-full bg-gray-400 p-1 text-xs" />
</span>
);
}
return (
<span className="flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-xs font-medium text-white">
{pendingCount}
</span>
);
}

View File

@@ -13,25 +13,18 @@ const { url, title, description, isNew } = Astro.props;
<a
href={url}
class='relative flex h-full flex-col rounded-md border border-gray-200 bg-white p-2.5 hover:border-gray-400 sm:rounded-lg sm:p-5'
class='bg-gradient-to-r from-slate-900 to-amber-900 hover:from-stone-900 hover:to-stone-900 hover:bg-gray-100 flex flex-col p-2.5 sm:p-5 rounded-md sm:rounded-lg border border-gray-200 relative h-full'
>
<span
class='text-md mb-0 font-semibold text-gray-900 hover:text-black sm:mb-1.5 sm:text-xl'
class='font-regular sm:font-medium text-md sm:text-xl hover:text-gray-50 text-gray-200 sm:text-gray-100 mb-0 sm:mb-1.5'
>
{title}
</span>
<span
class='hidden text-sm leading-normal text-gray-400 sm:block'
set:html={description}
/>
<span class='text-sm leading-normal text-gray-400 hidden sm:block'>{description}</span>
{
isNew && (
<span class='flex items-center gap-1.5 absolute bottom-1.5 right-1 rounded-sm text-xs font-semibold uppercase text-purple-500 sm:px-1.5'>
<span class='relative flex h-2 w-2'>
<span class='absolute inline-flex h-full w-full animate-ping rounded-full bg-purple-400 opacity-75' />
<span class='relative inline-flex h-2 w-2 rounded-full bg-purple-500' />
</span>
<span class='absolute bottom-1 right-1 bg-yellow-300 text-yellow-900 text-xs font-medium px-1 sm:px-1.5 py-0.5 rounded-sm uppercase'>
New
</span>
)

View File

@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import { EmptyProgress } from './EmptyProgress';
import { httpGet } from '../../lib/http';
import { HeroRoadmaps } from './HeroRoadmaps';
import {isLoggedIn} from "../../lib/jwt";
import { ProgressList } from './ProgressList';
export type UserProgressResponse = {
resourceId: string;
@@ -49,11 +48,6 @@ function renderProgress(progressList: UserProgressResponse) {
}
export function FavoriteRoadmaps() {
const isAuthenticated = isLoggedIn();
if (!isAuthenticated) {
return null;
}
const [isPreparing, setIsPreparing] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [progress, setProgress] = useState<UserProgressResponse>([]);
@@ -115,14 +109,14 @@ export function FavoriteRoadmaps() {
return (
<div
className={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
class={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
hasProgress && `border-t border-t-[#1e293c]`
}`}
>
<div className="container min-h-full">
{!isLoading && progress.length == 0 && <EmptyProgress />}
{progress.length > 0 && (
<HeroRoadmaps customRoadmaps={[]} progress={progress} isLoading={isLoading} />
<ProgressList progress={progress} isLoading={isLoading} />
)}
</div>
</div>

View File

@@ -1,155 +0,0 @@
import type { UserProgressResponse } from './FavoriteRoadmaps';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
import { Spinner } from '../ReactIcons/Spinner';
import type { ResourceType } from '../../lib/resource-progress';
import { MapIcon } from 'lucide-react';
type ProgressRoadmapProps = {
url: string;
percentageDone: number;
allowFavorite?: boolean;
resourceId: string;
resourceType: ResourceType;
resourceTitle: string;
isFavorite?: boolean;
};
function HeroRoadmap(props: ProgressRoadmapProps) {
const {
url,
percentageDone,
resourceType,
resourceId,
resourceTitle,
isFavorite,
allowFavorite = true,
} = props;
return (
<a
href={url}
className="relative flex flex-col overflow-hidden rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300"
>
<span className="relative z-20">{resourceTitle}</span>
<span
className="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
style={{ width: `${percentageDone}%` }}
></span>
{allowFavorite && (
<MarkFavorite
resourceId={resourceId}
resourceType={resourceType}
favorite={isFavorite}
/>
)}
</a>
);
}
type ProgressTitleProps = {
icon: any;
isLoading?: boolean;
title: string;
};
export function HeroTitle(props: ProgressTitleProps) {
const { isLoading = false, title, icon } = props;
return (
<p className="mb-4 flex items-center text-sm text-gray-400">
{!isLoading && icon}
{isLoading && (
<span className="mr-1.5">
<Spinner />
</span>
)}
{title}
</p>
);
}
type ProgressListProps = {
progress: UserProgressResponse;
showCustomRoadmaps?: boolean;
customRoadmaps: any[]; // @fixme implement this
isLoading?: boolean;
};
export function HeroRoadmaps(props: ProgressListProps) {
const {
progress,
isLoading = false,
customRoadmaps = [{} /* @fixme implement this */],
showCustomRoadmaps = false,
} = props;
return (
<div className="relative pb-12 pt-4 sm:pt-7">
{
<HeroTitle
icon={
(<CheckIcon additionalClasses="mr-1.5 h-[14px] w-[14px]" />) as any
}
isLoading={isLoading}
title="Your progress and favorite roadmaps."
/>
}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{progress.map((resource) => (
<HeroRoadmap
key={resource.resourceId}
resourceId={resource.resourceId}
resourceType={resource.resourceType}
resourceTitle={resource.resourceTitle}
isFavorite={resource.isFavorite}
percentageDone={
((resource.skipped + resource.done) / resource.total) * 100
}
url={
resource.resourceType === 'roadmap'
? `/${resource.resourceId}`
: `/best-practices/${resource.resourceId}`
}
/>
))}
</div>
{showCustomRoadmaps && (
<div className="mt-5">
{
<HeroTitle
icon={<MapIcon className="mr-1.5 h-[14px] w-[14px]" />}
title="Your custom roadmaps"
/>
}
{customRoadmaps.length === 0 && (
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
You haven't created any custom roadmaps yet.{' '}
<button className="text-gray-500 underline underline-offset-2 hover:text-gray-400">
Create one!
</button>
</p>
)}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{customRoadmaps.map((customRoadmap) => (
<HeroRoadmap
resourceId={'343434'}
resourceType={'roadmap'}
resourceTitle={'Frontend Roadmap Revised'}
percentageDone={50}
url={`/r?${'34343434'}`}
allowFavorite={false}
/>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -24,5 +24,5 @@ import { FavoriteRoadmaps } from './FavoriteRoadmaps';
their career.
</p>
</div>
<FavoriteRoadmaps client:only="react" />
<FavoriteRoadmaps client:authenticated />
</div>

View File

@@ -0,0 +1,61 @@
import type { UserProgressResponse } from './FavoriteRoadmaps';
import { CheckIcon } from '../ReactIcons/CheckIcon';
import { MarkFavorite } from '../FeaturedItems/MarkFavorite';
import { Spinner } from '../ReactIcons/Spinner';
type ProgressListProps = {
progress: UserProgressResponse;
isLoading?: boolean;
};
export function ProgressList(props: ProgressListProps) {
const { progress, isLoading = false } = props;
return (
<div className="relative pb-12 pt-4 sm:pt-7">
<p className="mb-4 flex items-center text-sm text-gray-400">
{!isLoading && (
<CheckIcon additionalClasses={'mr-1.5 w-[14px] h-[14px]'} />
)}
{isLoading && (
<span className="mr-1.5">
<Spinner />
</span>
)}
Your progress and favorite roadmaps.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
{progress.map((resource) => {
const url =
resource.resourceType === 'roadmap'
? `/${resource.resourceId}`
: `/best-practices/${resource.resourceId}`;
const percentageDone =
((resource.skipped + resource.done) / resource.total) * 100;
return (
<a
key={resource.resourceId}
href={url}
className="relative flex flex-col overflow-hidden rounded-md border border-slate-800 bg-slate-900 p-3 text-sm text-slate-400 hover:border-slate-600 hover:text-slate-300"
>
<span className="relative z-20">{resource.resourceTitle}</span>
<span
class="absolute bottom-0 left-0 top-0 z-10 bg-[#172a3a]"
style={{ width: `${percentageDone}%` }}
></span>
<MarkFavorite
resourceId={resource.resourceId}
resourceType={resource.resourceType}
favorite={resource.isFavorite}
/>
</a>
);
})}
</div>
</div>
);
}

View File

@@ -30,14 +30,6 @@ import Icon from '../AstroIcon.astro';
Profile
</a>
</li>
<li class='px-1'>
<a
href='/account/friends'
class='block rounded px-4 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700'
>
Friends
</a>
</li>
<li class='px-1'>
<a
href='/team'

View File

@@ -24,10 +24,10 @@ import AccountDropdown from './AccountDropdown.astro';
>
</li>
<li class='hidden lg:inline'>
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a>
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
</li>
<li class='hidden lg:inline'>
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
<a href='/videos' class='text-gray-400 hover:text-white'>Videos</a>
</li>
<li>
<kbd

View File

@@ -1,12 +1,8 @@
import Cookies from 'js-cookie';
import { TOKEN_COOKIE_NAME } from '../../lib/jwt';
import { TOKEN_COOKIE_NAME } from "../../lib/jwt";
export function logout() {
Cookies.remove(TOKEN_COOKIE_NAME, {
path: '/',
domain: import.meta.env.DEV ? 'localhost' : '.roadmap.sh',
});
Cookies.remove(TOKEN_COOKIE_NAME);
// Reloading will automatically redirect the user if required
window.location.reload();
}
@@ -19,8 +15,6 @@ function bindEvents() {
...target.closest('button')?.dataset,
};
const accountDropdown = document.querySelector('[data-account-dropdown]');
// If the user clicks on the logout button, remove the token cookie
if (dataset.logoutButton !== undefined) {
e.preventDefault();
@@ -29,12 +23,6 @@ function bindEvents() {
document.querySelector('[data-mobile-nav]')?.classList.remove('hidden');
} else if (dataset.closeMobileNav !== undefined) {
document.querySelector('[data-mobile-nav]')?.classList.add('hidden');
} else if (
accountDropdown &&
!target?.closest('[data-account-dropdown]') &&
!accountDropdown.classList.contains('hidden')
) {
accountDropdown.classList.add('hidden');
}
});
@@ -48,10 +36,10 @@ function bindEvents() {
});
document
.querySelector('[data-command-menu]')
?.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('command.k'));
});
.querySelector('[data-command-menu]')
?.addEventListener('click', () => {
window.dispatchEvent(new CustomEvent('command.k'));
});
}
bindEvents();

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import { httpGet, httpPatch, httpPost } from '../../lib/http';
import { pageProgressMessage } from '../../stores/page';
import type { TeamMemberDocument } from '../TeamMembers/TeamMembersPage';
@@ -62,7 +62,7 @@ export function NotificationPage() {
return (
<div>
<div className="mb-8 hidden md:block">
<div class="mb-8 hidden md:block">
<h2 className="text-3xl font-bold sm:text-4xl">Notification</h2>
<p className="mt-2 text-gray-400">Manage your notifications</p>
</div>
@@ -91,14 +91,14 @@ export function NotificationPage() {
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
onClick={() => respondInvitation('accept', notification?._id!)}
>
<img src={AcceptIcon.src} className="h-4 w-4" />
<img src={AcceptIcon} className="h-4 w-4" />
</button>
<button type="button"
disabled={isLoading}
className="inline-flex border p-1 rounded hover:bg-gray-50 disabled:opacity-75"
onClick={() => respondInvitation('reject', notification?._id!)}
>
<img alt={'Close'} src={XIcon.src} className="h-4 w-4" />
<img src={XIcon} className="h-4 w-4" />
</button>
</div>
</div>

View File

@@ -6,8 +6,8 @@ const starCount = await getFormattedStars('kamranahmedse/developer-roadmap');
---
<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>
<div class='max-w-[600px] container'>
<h2 class='text-2xl sm:text-5xl font-bold'>Community</h2>
<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'

View File

@@ -1,7 +1,7 @@
import { useStore } from '@nanostores/react';
import { useStore } from '@nanostores/preact';
import SpinnerIcon from '../icons/spinner.svg';
import { pageProgressMessage } from '../stores/page';
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
export interface Props {
initialMessage: string;
@@ -29,9 +29,9 @@ export function PageProgress(props: Props) {
<div>
{/* 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 ">
<div class="flex items-center justify-center rounded-md border bg-white px-4 py-2 ">
<img
src={SpinnerIcon.src}
src={SpinnerIcon}
alt="Loading"
className="h-4 w-4 animate-spin fill-blue-600 text-gray-200 sm:h-4 sm:w-4"
/>

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react';
import { useStore } from '@nanostores/preact';
import { useEffect, useState } from 'preact/hooks';
import CloseIcon from '../icons/close.svg';
import { httpGet } from '../lib/http';
import { sponsorHidden } from '../stores/page';
import { useStore } from '@nanostores/react';
export type PageSponsorType = {
company: string;
@@ -77,14 +77,15 @@ export function PageSponsor(props: PageSponsorProps) {
return null;
}
const { url, title, imageUrl, description, company, gaLabel } = sponsor;
const { url, title, imageUrl, description, company, gaLabel, pageUrl } =
sponsor;
return (
<a
href={url}
target="_blank"
rel="noopener sponsored nofollow"
className="fixed bottom-[15px] right-[15px] z-50 flex max-w-[350px] bg-white shadow-lg outline-0 outline-transparent"
class="fixed bottom-[15px] right-[15px] z-50 flex max-w-[350px] bg-white shadow-lg outline-0 outline-transparent"
onClick={() => {
window.fireEvent({
category: 'SponsorClick',
@@ -94,26 +95,28 @@ export function PageSponsor(props: PageSponsorProps) {
}}
>
<span
className="absolute right-1.5 top-1.5 text-gray-300 hover:text-gray-800"
class="absolute right-1.5 top-1.5 text-gray-300 hover:text-gray-800"
aria-label="Close"
onClick={(e) => {
e.preventDefault();
e.stopImmediatePropagation();
sponsorHidden.set(true);
}}
>
<img alt="Close" className="h-4 w-4" src={CloseIcon.src} />
<img alt="Close" class="h-4 w-4" src={CloseIcon} />
</span>
<img
src={imageUrl}
className="block h-[150px] w-[104.89px] object-contain lg:h-[169px] lg:w-[118.18px]"
class="block h-[150px] w-[104.89px] object-contain lg:h-[169px] lg:w-[118.18px]"
alt="Sponsor Banner"
/>
<span className="flex flex-1 flex-col justify-between text-sm">
<span className="p-[10px]">
<span className="mb-0.5 block font-semibold">{title}</span>
<span className="block text-gray-500">{description}</span>
<span class="flex flex-1 flex-col justify-between text-sm">
<span class="p-[10px]">
<span class="mb-0.5 block font-semibold">{title}</span>
<span class="block text-gray-500">{description}</span>
</span>
<span className="sponsor-footer">Partner Content</span>
<span class="sponsor-footer">Partner Content</span>
</span>
</a>
);

View File

@@ -5,9 +5,9 @@ import Popup from './Popup/Popup.astro';
<Popup id='progress-help' title='' subtitle=''>
<div class='-mt-2.5'>
<p class='mb-3 text-2xl font-semibold leading-5 text-gray-900'>
<h2 class='mb-3 text-2xl font-semibold leading-5 text-gray-900'>
Track your Progress
</p>
</h2>
<p class='text-sm leading-4 text-gray-600'>
Login and use one of the options listed below.
</p>

View File

@@ -1,142 +0,0 @@
/**
* atom-dark theme for `prism.js`
* Based on Atom's `atom-dark` theme: https://github.com/atom/atom-dark-syntax
* @author Joe Gibson (@gibsjose)
*/
code[class*="language-"],
pre[class*="language-"] {
color: #c5c8c6;
text-shadow: 0 1px rgba(0, 0, 0, 0.3);
font-family: Inconsolata, Monaco, Consolas, 'Courier New', Courier, monospace;
direction: ltr;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
line-height: 1.5;
-moz-tab-size: 4;
-o-tab-size: 4;
tab-size: 4;
-webkit-hyphens: none;
-moz-hyphens: none;
-ms-hyphens: none;
hyphens: none;
}
/* Code blocks */
pre[class*="language-"] {
padding: 1em;
margin: .5em 0;
overflow: auto;
border-radius: 0.3em;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: #1d1f21;
}
/* Inline code */
:not(pre) > code[class*="language-"] {
padding: .1em;
border-radius: .3em;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #7C7C7C;
}
.token.punctuation {
color: #c5c8c6;
}
.namespace {
opacity: .7;
}
.token.property,
.token.keyword,
.token.tag {
color: #96CBFE;
}
.token.class-name {
color: #FFFFB6;
text-decoration: underline;
}
.token.boolean,
.token.constant {
color: #99CC99;
}
.token.symbol,
.token.deleted {
color: #f92672;
}
.token.number {
color: #FF73FD;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #A8FF60;
}
.token.variable {
color: #C6C5FE;
}
.token.operator {
color: #EDEDED;
}
.token.entity {
color: #FFFFB6;
cursor: help;
}
.token.url {
color: #96CBFE;
}
.language-css .token.string,
.style .token.string {
color: #87C38A;
}
.token.atrule,
.token.attr-value {
color: #F9EE98;
}
.token.function {
color: #DAD085;
}
.token.regex {
color: #E9C062;
}
.token.important {
color: #fd971f;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}

View File

@@ -1,125 +0,0 @@
import { Fragment, useEffect, useRef, useState } from 'react';
import type { QuestionType } from '../../lib/question-group';
import { markdownToHtml } from '../../lib/markdown';
import Prism from 'prismjs';
import './PrismAtom.css';
type QuestionCardProps = {
question: QuestionType;
};
export function QuestionCard(props: QuestionCardProps) {
const { question } = props;
const [isAnswerVisible, setIsAnswerVisible] = useState<boolean>(false);
const answerRef = useRef<HTMLDivElement>(null);
const questionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// set the height of the question width to the height of the answer
// width if the answer is visible and the question height is less than
// the answer height
if (isAnswerVisible) {
Prism.highlightAll();
const answerHeight = answerRef.current?.clientHeight || 0;
const questionHeight = questionRef.current?.clientHeight || 0;
if (answerHeight > questionHeight) {
questionRef.current!.style.height = `${answerHeight}px`;
}
} else {
questionRef.current!.style.height = `auto`;
}
// if the user has scrolled down and the top of the answer is not
// visible, scroll to the top of the answer
const questionTop =
(questionRef.current?.getBoundingClientRect().top || 0) - 147;
if (questionTop < 0) {
window.scrollTo({
top: window.scrollY + questionTop - 10,
});
}
}, [isAnswerVisible]);
useEffect(() => {
setIsAnswerVisible(false);
}, [question]);
return (
<>
<div
ref={questionRef}
className={`flex flex-grow flex-col items-center justify-center py-5 sm:py-8`}
>
<div className="hidden text-gray-400 sm:block">
{question.topics?.map((topic, counter) => {
const totalTopics = question.topics?.length || 0;
return (
<Fragment key={topic}>
<span className="capitalize">{topic}</span>
{counter !== totalTopics - 1 && (
<span className="mx-2">&middot;</span>
)}
</Fragment>
);
})}
</div>
<div className="mx-auto flex max-w-[550px] flex-1 items-center justify-center py-3 sm:py-8">
<p className="px-4 text-xl font-semibold !leading-snug text-black sm:text-3xl">
{question.question}
</p>
</div>
<div className="text-center">
<button
onClick={() => {
setIsAnswerVisible(true);
}}
className="cursor-pointer text-sm text-gray-500 underline underline-offset-4 transition-colors hover:text-black sm:text-base"
>
Click to Reveal the Answer
</button>
</div>
</div>
<div
ref={answerRef}
className={`absolute left-0 right-0 flex flex-col items-center justify-center rounded-[7px] bg-neutral-100 py-4 text-sm leading-normal text-black transition-all duration-300 sm:py-8 sm:text-xl ${
isAnswerVisible ? 'top-0 min-h-[248px] sm:min-h-[398px]' : 'top-full'
}`}
>
{!question.isLongAnswer && (
<div
className={`mx-auto flex max-w-[600px] flex-grow flex-col items-center justify-center py-0 px-5 text-center text-base [&>p]:leading-relaxed sm:text-xl`}
dangerouslySetInnerHTML={{
__html: markdownToHtml(question.answer, false),
}}
/>
)}
{question.isLongAnswer && (
<div
className={`qa-answer prose prose-sm prose-quoteless mx-auto flex w-full max-w-[600px] flex-grow flex-col items-start justify-center py-0 px-4 text-left text-sm prose-h1:mb-2.5 prose-h1:mt-7 prose-h2:mb-3 prose-h2:mt-0 prose-h3:mb-[5px] prose-h3:mt-[10px] prose-p:mb-2 prose-p:mt-0 prose-blockquote:font-normal prose-blockquote:not-italic prose-blockquote:text-gray-700 prose-pre:!mb-6 prose-pre:w-full prose-ul:my-2 prose-li:m-0 prose-li:mb-0.5 sm:px-5 sm:text-lg sm:prose-p:mb-4`}
dangerouslySetInnerHTML={{
__html: markdownToHtml(question.answer, false),
}}
/>
)}
<div className="mt-7 text-center">
<button
onClick={() => {
setIsAnswerVisible(false);
}}
className="cursor-pointer text-sm text-gray-500 underline underline-offset-4 transition-colors hover:text-black sm:text-base"
>
Hide the Answer
</button>
</div>
</div>
</>
);
}

View File

@@ -1,99 +0,0 @@
import type { ReactNode } from 'react';
import {
PartyPopper,
RefreshCcw,
SkipForward,
Sparkles,
ThumbsUp,
} from 'lucide-react';
import type { QuestionProgressType } from './QuestionsList';
type ProgressStatButtonProps = {
isDisabled?: boolean;
icon: ReactNode;
label: string;
count: number;
onClick: () => void;
};
function ProgressStatButton(props: ProgressStatButtonProps) {
const { icon, label, count, onClick, isDisabled = false } = props;
return (
<button
disabled={isDisabled}
onClick={onClick}
className="group relative text-sm sm:text-base flex flex-1 items-center overflow-hidden rounded-md sm:rounded-xl border border-gray-300 bg-white py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black disabled:pointer-events-none disabled:opacity-50"
>
{icon}
<span className="flex flex-grow justify-between">
<span>{label}</span>
<span>{count}</span>
</span>
<span className="absolute top-full left-0 right-0 flex h-full items-center justify-center border border-black bg-black text-white transition-all duration-200 group-hover:top-0">
Restart Asking
</span>
</button>
);
}
type QuestionFinishedProps = {
knowCount: number;
didNotKnowCount: number;
skippedCount: number;
totalCount: number;
onReset: (type: QuestionProgressType | 'reset') => void;
};
export function QuestionFinished(props: QuestionFinishedProps) {
const { knowCount, didNotKnowCount, skippedCount, totalCount, onReset } =
props;
return (
<div className="relative flex flex-grow flex-col items-center justify-center px-4 sm:px-0">
<PartyPopper className="mb-4 mt-10 h-14 w-14 text-gray-300 sm:mt-0 sm:h-24 sm:w-24" />
<h1 className="text-lg font-semibold text-gray-700 sm:text-2xl">
Questions Finished
</h1>
<p className="mt-0 text-sm text-gray-500 sm:mt-2 sm:text-base">
Click below revisit{' '}
<span className="hidden sm:inline">specific or all questions</span>{' '}
<span className="inline sm:hidden">questions</span>
</p>
<div className="mt-5 mb-5 flex w-full flex-col gap-1.5 sm:gap-3 px-2 sm:flex-row sm:px-16">
<ProgressStatButton
icon={<ThumbsUp className="mr-1 h-4" />}
label="Knew"
count={knowCount}
isDisabled={knowCount === 0}
onClick={() => onReset('know')}
/>
<ProgressStatButton
icon={<Sparkles className="mr-1 h-4" />}
label="Learned"
count={didNotKnowCount}
isDisabled={didNotKnowCount === 0}
onClick={() => onReset('dontKnow')}
/>
<ProgressStatButton
icon={<SkipForward className="mr-1 h-4" />}
label="Skipped"
count={skippedCount}
isDisabled={skippedCount === 0}
onClick={() => onReset('skip')}
/>
</div>
<div className="mt-2 mb-4 sm:mb-0 text-sm">
<button
onClick={() => onReset('reset')}
className="flex items-center gap-0.5 text-red-700 hover:text-black text-sm sm:text-base"
>
<RefreshCcw className="mr-1 h-4" />
Restart Asking
</button>
</div>
</div>
);
}

View File

@@ -1,12 +0,0 @@
import {Spinner} from "../ReactIcons/Spinner";
export function QuestionLoader() {
return (
<div className="flex flex-grow flex-col items-center justify-center">
<p className="text-xl font-medium text-gray-500 flex items-center gap-3.5">
<Spinner isDualRing={false} innerFill='#6b7280' className="h-5 w-5" />
Please wait ..
</p>
</div>
);
}

View File

@@ -1,286 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { QuestionsProgress } from './QuestionsProgress';
import { CheckCircle, SkipForward, Sparkles } from 'lucide-react';
import { QuestionCard } from './QuestionCard';
import { QuestionLoader } from './QuestionLoader';
import { isLoggedIn } from '../../lib/jwt';
import type { QuestionType } from '../../lib/question-group';
import { httpGet, httpPut } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { QuestionFinished } from './QuestionFinished';
import { Confetti } from '../Confetti';
type UserQuestionProgress = {
know: string[];
dontKnow: string[];
skip: string[];
};
export type QuestionProgressType = keyof UserQuestionProgress;
type QuestionsListProps = {
groupId: string;
questions: QuestionType[];
};
export function QuestionsList(props: QuestionsListProps) {
const { questions: unshuffledQuestions, groupId } = props;
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [showConfetti, setShowConfetti] = useState(false);
const [questions, setQuestions] = useState<QuestionType[]>();
const [pendingQuestions, setPendingQuestions] = useState<QuestionType[]>([]);
const [userProgress, setUserProgress] = useState<UserQuestionProgress>();
const containerRef = useRef<HTMLDivElement>(null);
async function fetchUserProgress(): Promise<
UserQuestionProgress | undefined
> {
if (!isLoggedIn()) {
return;
}
const { response, error } = await httpGet<UserQuestionProgress>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-get-user-question-progress/${groupId}`
);
if (error) {
toast.error(error.message || 'Error fetching user progress');
return;
}
return response;
}
async function loadQuestions() {
const userProgress = await fetchUserProgress();
setUserProgress(userProgress);
const knownQuestions = userProgress?.know || [];
const didNotKnowQuestions = userProgress?.dontKnow || [];
const skipQuestions = userProgress?.skip || [];
const pendingQuestions = unshuffledQuestions.filter((question) => {
return (
!knownQuestions.includes(question.id) &&
!didNotKnowQuestions.includes(question.id) &&
!skipQuestions.includes(question.id)
);
});
// Shuffle and set pending questions
setPendingQuestions(pendingQuestions.sort(() => Math.random() - 0.5));
setQuestions(unshuffledQuestions);
setIsLoading(false);
}
async function resetProgress(type: QuestionProgressType | 'reset' = 'reset') {
let knownQuestions = userProgress?.know || [];
let didNotKnowQuestions = userProgress?.dontKnow || [];
let skipQuestions = userProgress?.skip || [];
if (!isLoggedIn()) {
if (type === 'know') {
knownQuestions = [];
} else if (type === 'dontKnow') {
didNotKnowQuestions = [];
} else if (type === 'skip') {
skipQuestions = [];
} else if (type === 'reset') {
knownQuestions = [];
didNotKnowQuestions = [];
skipQuestions = [];
}
} else {
setIsLoading(true);
const { response, error } = await httpPut<UserQuestionProgress>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-reset-question-progress/${groupId}`,
{
status: type,
}
);
if (error) {
toast.error(error.message || 'Error resetting progress');
return;
}
knownQuestions = response?.know || [];
didNotKnowQuestions = response?.dontKnow || [];
skipQuestions = response?.skip || [];
}
const pendingQuestions = unshuffledQuestions.filter((question) => {
return (
!knownQuestions.includes(question.id) &&
!didNotKnowQuestions.includes(question.id) &&
!skipQuestions.includes(question.id)
);
});
setUserProgress({
know: knownQuestions,
dontKnow: didNotKnowQuestions,
skip: skipQuestions,
});
setPendingQuestions(pendingQuestions.sort(() => Math.random() - 0.5));
setIsLoading(false);
}
async function updateQuestionStatus(
status: QuestionProgressType,
questionId: string
) {
setIsLoading(true);
let newProgress = userProgress || { know: [], dontKnow: [], skip: [] };
if (!isLoggedIn()) {
if (status === 'know') {
newProgress.know.push(questionId);
} else if (status == 'dontKnow') {
newProgress.dontKnow.push(questionId);
} else if (status == 'skip') {
newProgress.skip.push(questionId);
}
} else {
const { response, error } = await httpPut<UserQuestionProgress>(
`${
import.meta.env.PUBLIC_API_URL
}/v1-update-question-status/${groupId}`,
{
status,
questionId,
questionGroupId: groupId,
}
);
if (error || !response) {
toast.error(error?.message || 'Error marking question status');
return;
}
newProgress = response;
}
const updatedQuestionList = pendingQuestions.filter(
(q) => q.id !== questionId
);
setUserProgress(newProgress);
setPendingQuestions(updatedQuestionList);
setIsLoading(false);
if (updatedQuestionList.length === 0) {
setShowConfetti(true);
}
}
useEffect(() => {
loadQuestions().then(() => null);
}, [unshuffledQuestions]);
const knowCount = userProgress?.know.length || 0;
const dontKnowCount = userProgress?.dontKnow.length || 0;
const skipCount = userProgress?.skip.length || 0;
const hasProgress = knowCount > 0 || dontKnowCount > 0 || skipCount > 0;
const currQuestion = pendingQuestions[0];
const hasFinished = !isLoading && hasProgress && !currQuestion;
return (
<div className="mb-0 sm:mb-40 gap-3 text-center">
<QuestionsProgress
knowCount={knowCount}
didNotKnowCount={dontKnowCount}
skippedCount={skipCount}
totalCount={unshuffledQuestions?.length || questions?.length}
isLoading={isLoading}
showLoginAlert={!isLoggedIn() && hasProgress}
onResetClick={() => {
resetProgress('reset').finally(() => null);
}}
/>
{showConfetti && containerRef.current && (
<Confetti
pieces={100}
element={containerRef.current}
onDone={() => {
setShowConfetti(false);
}}
/>
)}
<div
ref={containerRef}
className="relative mb-4 flex min-h-[250px] w-full overflow-hidden rounded-lg border border-gray-300 bg-white sm:min-h-[400px]"
>
{hasFinished && (
<QuestionFinished
totalCount={unshuffledQuestions?.length || questions?.length || 0}
knowCount={knowCount}
didNotKnowCount={dontKnowCount}
skippedCount={skipCount}
onReset={(type: QuestionProgressType | 'reset') => {
resetProgress(type).finally(() => null);
}}
/>
)}
{!isLoading && currQuestion && <QuestionCard question={currQuestion} />}
{isLoading && <QuestionLoader />}
</div>
<div
className={`flex flex-col gap-1 sm:gap-3 transition-opacity duration-300 sm:flex-row ${
hasFinished ? 'opacity-0' : 'opacity-100'
}`}
>
<button
disabled={isLoading || !currQuestion}
onClick={(e) => {
e.stopPropagation();
e.preventDefault()
updateQuestionStatus('know', currQuestion.id).finally(() => null);
}}
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-gray-300 bg-white text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50"
>
<CheckCircle className="mr-1 h-4 text-current" />
Already Know that
</button>
<button
onClick={() => {
updateQuestionStatus('dontKnow', currQuestion.id).finally(
() => null
);
}}
disabled={isLoading || !currQuestion}
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-gray-300 bg-white text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-black transition-colors hover:border-black hover:bg-black hover:text-white disabled:pointer-events-none disabled:opacity-50"
>
<Sparkles className="mr-1 h-4 text-current" />
Didn't Know that
</button>
<button
onClick={() => {
updateQuestionStatus('skip', currQuestion.id).finally(() => null);
}}
disabled={isLoading || !currQuestion}
data-next-question="skip"
className="flex flex-1 items-center rounded-md sm:rounded-lg border border-red-600 text-sm sm:text-base py-2 px-2 sm:py-3 sm:px-4 text-red-600 hover:bg-red-600 hover:text-white disabled:pointer-events-none disabled:opacity-50"
>
<SkipForward className="mr-1 h-4" />
Skip Question
</button>
</div>
</div>
);
}

View File

@@ -1,101 +0,0 @@
import { CheckCircle, RotateCcw, SkipForward, Sparkles } from 'lucide-react';
import { showLoginPopup } from '../../lib/popup';
type QuestionsProgressProps = {
isLoading?: boolean;
showLoginAlert?: boolean;
knowCount?: number;
didNotKnowCount?: number;
totalCount?: number;
skippedCount?: number;
onResetClick?: () => void;
};
export function QuestionsProgress(props: QuestionsProgressProps) {
const {
showLoginAlert,
isLoading = false,
knowCount = 0,
didNotKnowCount = 0,
totalCount = 0,
skippedCount = 0,
onResetClick = () => null,
} = props;
const totalSolved = knowCount + didNotKnowCount + skippedCount;
const donePercentage = (totalSolved / totalCount) * 100;
return (
<div className="mb-3 sm:mb-5 overflow-hidden rounded-lg border border-gray-300 bg-white p-4 sm:p-6">
<div className="mb-3 flex items-center text-gray-600">
<div className="relative w-full flex-1 rounded-xl bg-gray-200 p-1">
<div
className="duration-400 absolute bottom-0 left-0 top-0 rounded-xl bg-slate-800 transition-[width]"
style={{
width: `${donePercentage}%`,
}}
/>
</div>
<span className="ml-3 text-sm">
{totalSolved} / {totalCount}
</span>
</div>
<div className="relative -left-1 flex flex-col gap-2 text-sm text-black sm:flex-row sm:gap-3">
<span className="flex items-center">
<CheckCircle className="mr-1 h-4" />
<span>Knew</span>
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
<span className="tabular-nums">{knowCount}</span>{' '}
<span className="hidden lg:inline">Questions</span>
<span className="inline sm:hidden">Questions</span>
</span>
</span>
<span className="flex items-center">
<Sparkles className="mr-1 h-4" />
<span>Learnt</span>
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
<span className="tabular-nums">{didNotKnowCount}</span>{' '}
<span className="hidden lg:inline">Questions</span>
<span className="inline sm:hidden">Questions</span>
</span>
</span>
<span className="flex items-center">
<SkipForward className="mr-1 h-4" />
<span>Skipped</span>
<span className="ml-2 rounded-md bg-gray-200/80 px-1.5 font-medium text-black">
<span className="tabular-nums">{skippedCount}</span>{' '}
<span className="hidden lg:inline">Questions</span>
<span className="inline sm:hidden">Questions</span>
</span>
</span>
<button
disabled={isLoading}
onClick={onResetClick}
className="flex items-center text-red-600 transition-opacity duration-300 hover:text-red-900 disabled:opacity-50"
>
<RotateCcw className="mr-1 h-4" />
Reset
<span className='inline lg:hidden'>Progress</span>
</button>
</div>
{showLoginAlert && (
<p className="-mx-6 mt-6 -mb-6 border-t bg-yellow-100 py-3 text-sm text-yellow-900">
You progress is not saved. Please{' '}
<button
onClick={() => {
showLoginPopup();
}}
className="underline-offset-3 font-medium underline hover:text-black"
>
login to save your progress.
</button>
</p>
)}
</div>
);
}

View File

@@ -1,27 +0,0 @@
type CheckIconProps = {
additionalClasses?: string;
};
export function AddUserIcon(props: CheckIconProps) {
const { additionalClasses = 'mr-2 w-[20px] h-[20px]' } = 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={`relative ${additionalClasses}`}
>
<path d="M14 19a6 6 0 0 0-12 0" />
<circle cx="8" cy="9" r="4" />
<line x1="19" x2="19" y1="8" y2="14" />
<line x1="22" x2="16" y1="11" y2="11" />
</svg>
);
}

View File

@@ -1,26 +0,0 @@
type CheckIconProps = {
additionalClasses?: string;
};
export function AddedUserIcon(props: CheckIconProps) {
const { additionalClasses = 'mr-2 w-[20px] h-[20px]' } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
className={`relative ${additionalClasses}`}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 19a6 6 0 0 0-12 0" />
<circle cx="8" cy="9" r="4" />
<polyline points="16 11 18 13 22 9" />
</svg>
);
}

View File

@@ -10,7 +10,7 @@ export function CheckIcon(props: CheckIconProps) {
className={`relative ${additionalClasses}`}
stroke="currentColor"
fill="currentColor"
strokeWidth="0"
stroke-width="0"
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
>

View File

@@ -12,13 +12,13 @@ export function ChevronDownIcon(props: ChevronDownIconProps) {
width="24"
height="24"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke-width="1.5"
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>

View File

@@ -12,7 +12,7 @@ export function CloseIcon(props: CloseIconProps) {
width="24"
height="24"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke-width="1.5"
stroke="currentColor"
className={className}
>

View File

@@ -1,27 +0,0 @@
type CheckIconProps = {
additionalClasses?: string;
};
export function DeleteUserIcon(props: CheckIconProps) {
const { additionalClasses = 'mr-2 w-[20px] h-[20px]' } = props;
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
className={`relative ${additionalClasses}`}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<line x1="17" x2="22" y1="8" y2="13" />
<line x1="22" x2="17" y1="8" y2="13" />
</svg>
);
}

View File

@@ -18,23 +18,23 @@ export function ErrorIcon(props: ErrorIconProps) {
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M15 9L9 15"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 9L15 15"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);

View File

@@ -18,23 +18,23 @@ export function InfoIcon(props: InfoIconProps) {
d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 16V12"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 8H12.01"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);

View File

@@ -11,9 +11,9 @@ export function MailIcon(props: MailIconProps) {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className={className}
>
<rect width="20" height="16" x="2" y="4" rx="2" />

View File

@@ -11,9 +11,9 @@ export function ShareIcon(props: ShareIconProps) {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
{...props}
>
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />

View File

@@ -11,6 +11,7 @@ export function Spinner({
outerFill = '#404040',
innerFill = '#94a3b8',
}: SpinnerProps) {
className += className?.includes('w-') ? '' : ' w-3.5 h-3.5';
return (
@@ -22,15 +23,15 @@ export function Spinner({
>
{isDualRing && (
<path
fillRule="evenodd"
clipRule="evenodd"
fill-rule="evenodd"
clip-rule="evenodd"
d="M46.5 93C72.1812 93 93 72.1812 93 46.5C93 20.8188 72.1812 0 46.5 0C20.8188 0 0 20.8188 0 46.5C0 72.1812 20.8188 93 46.5 93ZM46.5 77C63.3447 77 77 63.3447 77 46.5C77 29.6553 63.3447 16 46.5 16C29.6553 16 16 29.6553 16 46.5C16 63.3447 29.6553 77 46.5 77Z"
style={{ fill: outerFill }}
style={`fill: ${outerFill};`}
></path>
)}
<path
d="M84.9746 49.5667C89.3257 49.9135 93.2042 46.6479 92.81 42.3008C92.3588 37.3251 91.1071 32.437 89.0872 27.8298C86.0053 20.7998 81.2311 14.6422 75.1905 9.90623C69.15 5.17027 62.031 2.00329 54.4687 0.687889C49.5126 -0.174203 44.467 -0.223422 39.5274 0.525737C35.2118 1.18024 32.966 5.72596 34.3411 9.86865V9.86865C35.7161 14.0113 40.2118 16.1424 44.5681 15.8677C46.9635 15.7166 49.3773 15.8465 51.7599 16.2609C56.7515 17.1291 61.4505 19.2196 65.4377 22.3456C69.4249 25.4717 72.5762 29.5362 74.6105 34.1764C75.5815 36.3912 76.2835 38.7044 76.7084 41.0666C77.4811 45.3626 80.6234 49.2199 84.9746 49.5667V49.5667Z"
style={{ fill: innerFill }}
style={`fill: ${innerFill};`}
></path>
</svg>
);

View File

@@ -1,25 +0,0 @@
type CheckIconProps = {
additionalClasses?: string;
};
export function StopIcon(props: CheckIconProps) {
const { additionalClasses = 'mr-2 w-[20px] h-[20px]' } = 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={`relative ${additionalClasses}`}
>
<circle cx="12" cy="12" r="10" />
<path d="m4.9 4.9 14.2 14.2" />
</svg>
);
}

View File

@@ -12,9 +12,9 @@ export function TrashIcon(props: TrashIconProps) {
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className={className}
>
<path d="M3 6h18" />

View File

@@ -18,23 +18,23 @@ export function WarningIcon(props: WarningIconProps) {
d="M21.7304 18.0002L13.7304 4.00022C13.556 3.69243 13.303 3.43641 12.9973 3.25829C12.6917 3.08017 12.3442 2.98633 11.9904 2.98633C11.6366 2.98633 11.2892 3.08017 10.9835 3.25829C10.6778 3.43641 10.4249 3.69243 10.2504 4.00022L2.25042 18.0002C2.0741 18.3056 1.98165 18.6521 1.98243 19.0047C1.98321 19.3573 2.0772 19.7035 2.25486 20.008C2.43253 20.3126 2.68757 20.5648 2.99411 20.7391C3.30066 20.9133 3.64783 21.0034 4.00042 21.0002H20.0004C20.3513 20.9999 20.6959 20.9072 20.9997 20.7315C21.3035 20.5558 21.5556 20.3033 21.7309 19.9993C21.9062 19.6954 21.9985 19.3506 21.9984 18.9997C21.9983 18.6488 21.9059 18.3041 21.7304 18.0002Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 9V13"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M12 17H12.01"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
);

View File

@@ -18,7 +18,7 @@ const relatedRoadmapDetails = await getRoadmapsByIds(relatedRoadmaps);
<div class='border-t bg-gray-100'>
<div class='container'>
<div class='flex justify-between relative -top-5'>
<span class='text-md font-medium py-1 px-3 border bg-white rounded-md'>Related Roadmaps</span>
<h1 class='text-md font-medium py-1 px-3 border bg-white rounded-md'>Related Roadmaps</h1>
<a href='/roadmaps' class='text-md font-medium py-1 px-3 border bg-white rounded-md hover:bg-gray-50'>
<span class='hidden sm:inline'>All Roadmaps &rarr;</span>
<span class='inline sm:hidden'>More &rarr;</span>

View File

@@ -5,10 +5,10 @@ import { ProgressShareButton } from './UserProgress/ProgressShareButton';
export interface Props {
resourceId: string;
resourceType: ResourceType;
hasSecondaryBanner?: boolean;
isSecondaryBanner?: boolean;
}
const { hasSecondaryBanner = false, resourceId, resourceType } = Astro.props;
const { isSecondaryBanner = false, resourceId, resourceType } = Astro.props;
---
<div
@@ -16,8 +16,8 @@ const { hasSecondaryBanner = false, resourceId, resourceType } = Astro.props;
class:list={[
'hidden sm:flex justify-between px-2 bg-white items-center py-1.5 relative striped-loader bg-white',
{
'rounded-tl-md rounded-tr-md': hasSecondaryBanner,
'rounded-md': !hasSecondaryBanner,
'rounded-bl-md rounded-br-md': isSecondaryBanner,
'rounded-md': !isSecondaryBanner,
},
]}
>
@@ -55,7 +55,7 @@ const { hasSecondaryBanner = false, resourceId, resourceType } = Astro.props;
<ProgressShareButton
resourceId={resourceId}
resourceType={resourceType}
client:only="react"
client:load
/>
<button
data-popup='progress-help'
@@ -86,7 +86,7 @@ const { hasSecondaryBanner = false, resourceId, resourceType } = Astro.props;
<ProgressShareButton
resourceId={resourceId}
resourceType={resourceType}
client:only="react"
client:load
/>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import { httpGet, httpPatch } from '../lib/http';
import BuildingIcon from '../icons/building.svg';
import ErrorIcon from '../icons/error.svg';
@@ -87,12 +87,12 @@ export function RespondInviteForm() {
<div className="container text-center">
<img
alt={'error'}
src={ErrorIcon.src}
src={ErrorIcon}
className="mx-auto mb-4 mt-24 w-20 opacity-20"
/>
<h2 className={'mb-1 text-2xl font-bold'}>Error</h2>
<p className="mb-4 text-base leading-6 text-gray-600">
<p class="mb-4 text-base leading-6 text-gray-600">
{error || 'There was a problem, please try again.'}
</p>
@@ -112,24 +112,24 @@ export function RespondInviteForm() {
<div className="container text-center">
<img
alt={'join team'}
src={BuildingIcon.src}
src={BuildingIcon}
className="mx-auto mb-4 mt-24 w-20 opacity-20"
/>
<h2 className={'mb-1 text-2xl font-bold'}>Join Team</h2>
<p className="mb-3 text-base leading-6 text-gray-600">
<p class="mb-3 text-base leading-6 text-gray-600">
You have been invited to join the team{' '}
<strong id="team-name">{invite?.team?.name}</strong>.
</p>
{!isAuthenticated && (
<div className="mx-auto w-full duration-500 sm:max-w-md">
<div className="flex w-full items-center gap-2">
<div class="mx-auto w-full duration-500 sm:max-w-md">
<div class="flex w-full items-center gap-2">
<button
onClick={() => showLoginPopup()}
data-popup="login-popup"
type="button"
className="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
class="flex-grow cursor-pointer rounded-lg bg-gray-200 px-3 py-2 text-center"
>
Login to respond
</button>

View File

@@ -24,7 +24,7 @@ export function Editor(props: EditorProps) {
</span>
)}
<img src={CopyIcon.src} alt="Copy" className="inline-block h-4 w-4" />
<img src={CopyIcon} alt="Copy" className="inline-block h-4 w-4" />
</button>
</div>
<textarea
@@ -34,8 +34,9 @@ export function Editor(props: EditorProps) {
e.target.select();
copyText(e.target.value);
}}
value={text}
/>
>
{text}
</textarea>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from 'preact/hooks';
import { useCopyText } from '../../hooks/use-copy-text';
import { useAuth } from '../../hooks/use-auth';
@@ -113,7 +113,7 @@ export function RoadCardPage() {
<div className="flex items-start gap-4 mx-0 sm:-mx-10 px-0 sm:px-10 border-b py-4">
<StepCounter step={4} />
<div className="flex-grow">
<div class="flex-grow">
<StepLabel label="Share your #RoadCard with others" />
<div className={'rounded-md border bg-gray-50 p-2 text-center'}>
<a
@@ -146,7 +146,7 @@ export function RoadCardPage() {
className="flex cursor-pointer items-center justify-center rounded border border-gray-300 p-1.5 px-2 text-sm font-medium disabled:bg-blue-50"
onClick={() => copyText(badgeUrl.toString())}
>
<img alt="Copy" src={CopyIcon.src} className="mr-1" />
<img alt="Copy" src={CopyIcon} className="mr-1" />
{isCopied ? 'Copied!' : 'Copy Link'}
</button>

View File

@@ -1,5 +1,5 @@
import { httpGet } from '../../lib/http';
import { useEffect, useState } from 'react';
import { useEffect, useState } from 'preact/hooks';
import { pageProgressMessage } from '../../stores/page';
import type { UserProgressResponse } from '../HeroSection/FavoriteRoadmaps';
import { SelectionButton } from './SelectionButton';
@@ -33,24 +33,19 @@ export function RoadmapSelect(props: RoadmapSelectProps) {
}, []);
const canSelectMore = selectedRoadmaps.length < 4;
const allProgress =
progressList?.filter((progress) => progress.resourceType === 'roadmap') ||
[];
const allProgress = progressList?.filter(
(progress) => progress.resourceType === 'roadmap'
) || [];
return (
<div className="flex flex-wrap gap-1">
{allProgress?.length === 0 && (
<p className={'text-sm italic text-gray-400'}>
No progress tracked so far.
</p>
)}
{allProgress?.length === 0 && <p className={'text-sm text-gray-400 italic'}>No progress tracked so far.</p>}
{allProgress?.map((progress) => {
const isSelected = selectedRoadmaps.includes(progress.resourceId);
const canSelect = isSelected || canSelectMore;
return (
<SelectionButton
key={progress.resourceId}
text={progress.resourceTitle}
isDisabled={!canSelect}
isSelected={isSelected}

View File

@@ -8,7 +8,6 @@ import YouTubeAlert from './YouTubeAlert.astro';
import ProgressHelpPopup from './ProgressHelpPopup.astro';
import { MarkFavorite } from './FeaturedItems/MarkFavorite';
import { TeamVersions } from './TeamVersions/TeamVersions';
import { RoadmapFrontmatter } from '../lib/roadmap';
export interface Props {
title: string;
@@ -18,7 +17,6 @@ export interface Props {
roadmapId: string;
isUpcoming?: boolean;
hasSearch?: boolean;
question?: RoadmapFrontmatter['question'];
hasTopics?: boolean;
}
@@ -31,43 +29,25 @@ const {
hasSearch = false,
note,
hasTopics = false,
question,
} = Astro.props;
const isRoadmapReady = !isUpcoming;
const roadmapTitle =
roadmapId === 'devops'
? 'DevOps'
: `${roadmapId.charAt(0).toUpperCase()}${roadmapId.slice(1)}`;
const hasTnsBanner = !!tnsBannerLink;
---
<LoginPopup />
<ProgressHelpPopup />
<div class='relative border-b'>
<div
class:list={[
'container relative py-5',
{
'sm:py-16': hasTnsBanner,
'sm:py-12': !hasTnsBanner,
},
]}
>
<div class='border-b'>
<div class='container relative py-5 sm:py-12'>
<div class='mb-3 mt-0 sm:mb-4'>
<h1 class='mb-0.5 text-2xl font-bold sm:mb-2 sm:text-4xl'>
{title}
<span class='relative top-0 sm:-top-1'>
<MarkFavorite
resourceId={roadmapId}
resourceType='roadmap'
className='text-gray-500 !opacity-100 hover:text-gray-600 [&>svg]:stroke-[0.4] [&>svg]:stroke-gray-400 hover:[&>svg]:stroke-gray-600 [&>svg]:h-4 [&>svg]:w-4 sm:[&>svg]:h-4 sm:[&>svg]:w-4 ml-1.5 relative focus:outline-0'
client:only='react'
/>
</span>
<MarkFavorite
resourceId={roadmapId}
resourceType='roadmap'
className='text-gray-500 !opacity-100 hover:text-gray-600 [&>svg]:stroke-[0.4] [&>svg]:stroke-gray-400 hover:[&>svg]:stroke-gray-600 [&>svg]:h-4 [&>svg]:w-4 sm:[&>svg]:h-5 sm:[&>svg]:w-5 ml-1.5 relative focus:outline-0'
client:load
/>
</h1>
<p class='text-sm text-gray-500 sm:text-lg'>{description}</p>
</div>
@@ -141,7 +121,7 @@ const hasTnsBanner = !!tnsBannerLink;
<TeamVersions
resourceType='roadmap'
resourceId={roadmapId}
client:only='react'
client:only
/>
{
@@ -164,31 +144,12 @@ const hasTnsBanner = !!tnsBannerLink;
<!-- Desktop: Roadmap Resources - Alert -->
{
hasTopics && (
<RoadmapHint
tnsBannerLink={tnsBannerLink}
titleQuestion={question?.title}
titleAnswer={question?.description}
roadmapId={roadmapId}
/>
<RoadmapHint roadmapId={roadmapId} tnsBannerLink={tnsBannerLink} />
)
}
{hasSearch && <TopicSearch />}
</div>
{
tnsBannerLink && (
<div class='absolute left-0 right-0 top-0 hidden border-b border-b-gray-200 px-2 py-1.5 sm:block'>
<p class='py-0.5 text-center text-sm'>
<span class='badge mr-1'>Partner</span>
Get the latest {roadmapTitle} news from our sister site{' '}
<a href={tnsBannerLink} target='_blank' class='font-medium underline'>
TheNewStack.io
</a>
</p>
</div>
)
}
</div>
{note && <RoadmapNote text={note} />}

View File

@@ -1,57 +1,47 @@
---
import AstroIcon from './AstroIcon.astro';
import Icon from './AstroIcon.astro';
import { RoadmapTitleQuestion } from './RoadmapTitleQuestion.tsx';
import ResourceProgressStats from './ResourceProgressStats.astro';
export interface Props {
roadmapId: string;
tnsBannerLink?: string;
titleQuestion?: string;
titleAnswer?: string;
}
const {
roadmapId,
titleQuestion = '',
titleAnswer = '',
tnsBannerLink,
} = Astro.props;
const hasTitleQuestion = titleQuestion && titleAnswer;
const hasTnsBanner = !!tnsBannerLink;
const { roadmapId, tnsBannerLink = '' } = Astro.props;
const hasTNSBanner = !!tnsBannerLink;
const roadmapTitle =
roadmapId === 'devops'
? 'DevOps'
: `${roadmapId.charAt(0).toUpperCase()}${roadmapId.slice(1)}`;
---
<div
class:list={[
'mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0 bg-white',
...(hasTnsBanner
? [
{
'sm:-mb-[110px]': hasTitleQuestion,
'sm:-mb-[81px]': !hasTitleQuestion,
},
]
: [
{
'sm:-mb-[88px]': hasTitleQuestion,
'sm:-mb-[65px]': !hasTitleQuestion,
},
]),
'mt-4 sm:mt-7 border-0 sm:border rounded-md mb-0',
{
'sm:-mb-[82px]': hasTNSBanner,
'sm:-mb-[65px]': !hasTNSBanner,
},
]}
>
<ResourceProgressStats
resourceId={roadmapId}
resourceType='roadmap'
hasSecondaryBanner={hasTitleQuestion}
/>
{
hasTitleQuestion && (
<RoadmapTitleQuestion
client:load
question={titleQuestion}
answer={titleAnswer}
/>
hasTNSBanner && (
<div class='hidden border-b bg-gray-100 px-2 py-1.5 sm:block'>
<p class='text-sm'>
Get the latest {roadmapTitle} news from our sister site{' '}
<a
href={tnsBannerLink}
target='_blank'
class='font-semibold underline'
>
TheNewStack.io
</a>
</p>
</div>
)
}
</div>
<ResourceProgressStats isSecondaryBanner={hasTNSBanner} resourceId={roadmapId} resourceType='roadmap' />
</div>

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