Compare commits
31 Commits
roadmap/co
...
feat/share
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8407dd7448 | ||
|
|
d659752ad2 | ||
|
|
6f9fe361ae | ||
|
|
036b34c6f3 | ||
|
|
93c2043f23 | ||
|
|
d2da3c8621 | ||
|
|
4aa8f15c07 | ||
|
|
ceb4c3b95d | ||
|
|
7ec5e30b51 | ||
|
|
e5e0a7c8c5 | ||
|
|
90f3ffe270 | ||
|
|
ce47a7433e | ||
|
|
21b8358683 | ||
|
|
e1751b105f | ||
|
|
e43bea7c40 | ||
|
|
5fa669aec2 | ||
|
|
4b8f868b2b | ||
|
|
a0743a8272 | ||
|
|
2cae13c090 | ||
|
|
0bf287f1d6 | ||
|
|
d7d819b4b3 | ||
|
|
29cff6a6f8 | ||
|
|
044df81b7a | ||
|
|
3151ee5021 | ||
|
|
e6ce9f40ee | ||
|
|
3b5e3c44f9 | ||
|
|
c286e0a6f8 | ||
|
|
3bebe0c1de | ||
|
|
9845fe624a | ||
|
|
4b2b2ebe8c | ||
|
|
82c2aaacc3 |
@@ -14,24 +14,12 @@ body:
|
||||
placeholder: e.g. Roadmap to learn Data Science
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
label: Is this roadmap prepared by you or someone else?
|
||||
options:
|
||||
- I prepared this roadmap
|
||||
- I found this roadmap online (please provide a link below)
|
||||
- type: textarea
|
||||
id: roadmap-description
|
||||
attributes:
|
||||
label: Roadmap Items
|
||||
description: Please submit a nested list of items which we can convert into the visual. Here is an [example of roadmap items list.](https://gist.github.com/kamranahmedse/98758d2c73799b3a6ce17385e4c548a5).
|
||||
label: Roadmap Link
|
||||
description: Please create the roadmap [using our roadmap editor](https://twitter.com/kamrify/status/1708293162693767426) and submit the roadmap link.
|
||||
placeholder: |
|
||||
- Item 1
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
- Item 2
|
||||
- Subitem 1
|
||||
- Subitem 2
|
||||
https://roadmap.sh/xyz
|
||||
validations:
|
||||
required: true
|
||||
required: true
|
||||
|
||||
2
.github/workflows/deploy.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v1
|
||||
|
||||
2
.github/workflows/update-deps.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
upgrade-deps:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
BIN
public/images/team-promo/contact.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
public/images/team-promo/documentation.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
public/images/team-promo/growth-plans.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
public/images/team-promo/hero-img.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
public/images/team-promo/hero.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
public/images/team-promo/invite-members.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
public/images/team-promo/many-roadmaps.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
public/images/team-promo/onboarding.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
public/images/team-promo/our-roadmaps.png
Normal file
|
After Width: | Height: | Size: 279 KiB |
BIN
public/images/team-promo/progress-tracking.png
Normal file
|
After Width: | Height: | Size: 296 KiB |
BIN
public/images/team-promo/roadmap-editor.png
Normal file
|
After Width: | Height: | Size: 773 KiB |
BIN
public/images/team-promo/sharing-settings.png
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
public/images/team-promo/skill-gap.png
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
public/images/team-promo/team-dashboard.png
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
public/images/team-promo/team-insights.png
Normal file
|
After Width: | Height: | Size: 275 KiB |
BIN
public/images/team-promo/update-progress.png
Normal file
|
After Width: | Height: | Size: 345 KiB |
@@ -9,8 +9,8 @@
|
||||
<a href="https://roadmap.sh/best-practices">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Best%20Practices-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="best practices" />
|
||||
</a>
|
||||
<a href="https://youtube.com/theroadmap?sub_confirmation=1">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Videos-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
<a href="https://roadmap.sh/questions">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-Questions-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="videos" />
|
||||
</a>
|
||||
<a href="https://www.youtube.com/channel/UCA0H2KIWgWTwpTFjSxp0now?sub_confirmation=1">
|
||||
<img src="https://img.shields.io/badge/%E2%9C%A8-YouTube%20Channel-0a0a0a.svg?style=flat&colorA=0a0a0a" alt="roadmaps" />
|
||||
|
||||
@@ -56,6 +56,12 @@ export function GitHubButton(props: GitHubButtonProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const authRedirectUrl = localStorage.getItem('authRedirect');
|
||||
if (authRedirectUrl) {
|
||||
localStorage.removeItem('authRedirect');
|
||||
redirectUrl = authRedirectUrl;
|
||||
}
|
||||
|
||||
localStorage.removeItem(GITHUB_REDIRECT_AT);
|
||||
localStorage.removeItem(GITHUB_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
|
||||
@@ -55,6 +55,12 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const authRedirectUrl = localStorage.getItem('authRedirect');
|
||||
if (authRedirectUrl) {
|
||||
localStorage.removeItem('authRedirect');
|
||||
redirectUrl = authRedirectUrl;
|
||||
}
|
||||
|
||||
localStorage.removeItem(GOOGLE_REDIRECT_AT);
|
||||
localStorage.removeItem(GOOGLE_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
@@ -86,10 +92,11 @@ export function GoogleButton(props: GoogleButtonProps) {
|
||||
// For non authentication pages, we want to redirect back to the page
|
||||
// the user was on before they clicked the social login button
|
||||
if (!['/login', '/signup'].includes(window.location.pathname)) {
|
||||
const pagePath =
|
||||
['/respond-invite', '/befriend'].includes(window.location.pathname)
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
const pagePath = ['/respond-invite', '/befriend'].includes(
|
||||
window.location.pathname
|
||||
)
|
||||
? window.location.pathname + window.location.search
|
||||
: window.location.pathname;
|
||||
|
||||
localStorage.setItem(GOOGLE_REDIRECT_AT, Date.now().toString());
|
||||
localStorage.setItem(GOOGLE_LAST_PAGE, pagePath);
|
||||
|
||||
@@ -55,6 +55,12 @@ export function LinkedInButton(props: LinkedInButtonProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const authRedirectUrl = localStorage.getItem('authRedirect');
|
||||
if (authRedirectUrl) {
|
||||
localStorage.removeItem('authRedirect');
|
||||
redirectUrl = authRedirectUrl;
|
||||
}
|
||||
|
||||
localStorage.removeItem(LINKEDIN_REDIRECT_AT);
|
||||
localStorage.removeItem(LINKEDIN_LAST_PAGE);
|
||||
Cookies.set(TOKEN_COOKIE_NAME, response.token, {
|
||||
|
||||
@@ -73,7 +73,10 @@ function handleAuthenticated() {
|
||||
|
||||
// If the user is on a guest route, redirect them to the home page
|
||||
if (guestRoutes.includes(window.location.pathname)) {
|
||||
window.location.href = '/';
|
||||
const authRedirect = window.localStorage.getItem('authRedirect') || '/';
|
||||
window.localStorage.removeItem('authRedirect');
|
||||
|
||||
window.location.href = authRedirect;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,14 @@ const defaultPages: PageType[] = [
|
||||
icon: GroupIcon.src,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'friends',
|
||||
url: '/account/friends',
|
||||
title: 'Friends',
|
||||
group: 'Pages',
|
||||
icon: GroupIcon.src,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'roadmaps',
|
||||
url: '/roadmaps',
|
||||
@@ -47,6 +55,14 @@ const defaultPages: PageType[] = [
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon.src,
|
||||
},
|
||||
{
|
||||
id: 'account-roadmaps',
|
||||
url: '/account/roadmaps',
|
||||
title: 'Custom Roadmaps',
|
||||
group: 'Pages',
|
||||
icon: RoadmapIcon.src,
|
||||
isProtected: true,
|
||||
},
|
||||
{
|
||||
id: 'best-practices',
|
||||
url: '/best-practices',
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useToast } from '../../hooks/use-toast';
|
||||
export type TeamResourceConfig = {
|
||||
isCustomResource: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
visibility?: AllowedRoadmapVisibility;
|
||||
resourceId: string;
|
||||
resourceType: string;
|
||||
|
||||
@@ -2,20 +2,17 @@ import { Plus } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../../lib/jwt';
|
||||
import { showLoginPopup } from '../../../lib/popup';
|
||||
import { cn } from '../../../lib/classname';
|
||||
import {
|
||||
type AllowedCustomRoadmapType,
|
||||
type AllowedRoadmapVisibility,
|
||||
CreateRoadmapModal,
|
||||
} from './CreateRoadmapModal';
|
||||
import { CreateRoadmapModal } from './CreateRoadmapModal';
|
||||
import { useState } from 'react';
|
||||
|
||||
type CreateRoadmapButtonProps = {
|
||||
className?: string;
|
||||
type?: AllowedCustomRoadmapType;
|
||||
text?: string;
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
|
||||
const { className, type } = props;
|
||||
const { teamId, className, text = 'Create your own Roadmap' } = props;
|
||||
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
|
||||
@@ -31,7 +28,7 @@ export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
|
||||
<>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal
|
||||
type={type}
|
||||
teamId={teamId}
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
}}
|
||||
@@ -46,7 +43,7 @@ export function CreateRoadmapButton(props: CreateRoadmapButtonProps) {
|
||||
onClick={toggleCreateRoadmapHandler}
|
||||
>
|
||||
<Plus size={16} />
|
||||
Create a new roadmap
|
||||
{text}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@ import { Modal } from '../../Modal';
|
||||
import { useToast } from '../../../hooks/use-toast';
|
||||
import { httpPost } from '../../../lib/http';
|
||||
import { cn } from '../../../lib/classname';
|
||||
import { allowedVisibilityLabels } from '../ShareRoadmapModal';
|
||||
|
||||
export const allowedRoadmapVisibility = [
|
||||
'me',
|
||||
@@ -46,12 +45,11 @@ interface CreateRoadmapModalProps {
|
||||
onClose: () => void;
|
||||
onCreated?: (roadmap: RoadmapDocument) => void;
|
||||
teamId?: string;
|
||||
type?: AllowedCustomRoadmapType;
|
||||
visibility?: AllowedRoadmapVisibility;
|
||||
}
|
||||
|
||||
export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
const { onClose, onCreated, teamId, type: defaultType = 'role' } = props;
|
||||
const { onClose, onCreated, teamId } = props;
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const toast = useToast();
|
||||
@@ -59,7 +57,6 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [type, setType] = useState<AllowedCustomRoadmapType>(defaultType);
|
||||
const isInvalidDescription = description?.trim().length > 80;
|
||||
|
||||
async function handleSubmit(
|
||||
@@ -71,7 +68,7 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (title.trim() === '' || isInvalidDescription || !type) {
|
||||
if (title.trim() === '' || isInvalidDescription) {
|
||||
toast.error('Please fill all the fields');
|
||||
return;
|
||||
}
|
||||
@@ -82,7 +79,6 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
{
|
||||
title,
|
||||
description,
|
||||
type,
|
||||
...(teamId && {
|
||||
teamId,
|
||||
}),
|
||||
@@ -114,7 +110,6 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setType('role');
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
@@ -182,33 +177,6 @@ export function CreateRoadmapModal(props: CreateRoadmapModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="type"
|
||||
className="block text-xs uppercase text-gray-400"
|
||||
>
|
||||
Type
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
required
|
||||
className="block w-full rounded-md border border-gray-300 px-2.5 py-2 outline-none focus:border-black sm:text-sm"
|
||||
value={type}
|
||||
onChange={(e) =>
|
||||
setType(e.target.value as AllowedCustomRoadmapType)
|
||||
}
|
||||
>
|
||||
{allowedCustomRoadmapType.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)} Based Roadmap
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn('mt-4 flex justify-between gap-2', teamId && 'mt-8')}
|
||||
>
|
||||
|
||||
@@ -40,6 +40,18 @@ export interface RoadmapContentDocument {
|
||||
}[];
|
||||
}
|
||||
|
||||
export type CreatorType = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
export type GetRoadmapResponse = RoadmapDocument & {
|
||||
canManage: boolean;
|
||||
creator?: CreatorType;
|
||||
team?: CreatorType;
|
||||
};
|
||||
|
||||
export function hideRoadmapLoader() {
|
||||
const loaderEl = document.querySelector(
|
||||
'[data-roadmap-loader]'
|
||||
@@ -53,7 +65,7 @@ export function CustomRoadmap() {
|
||||
const { id, secret } = getUrlParams() as { id: string; secret: string };
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [roadmap, setRoadmap] = useState<RoadmapDocument | null>(null);
|
||||
const [roadmap, setRoadmap] = useState<GetRoadmapResponse | null>(null);
|
||||
const [error, setError] = useState<AppError | FetchError | undefined>();
|
||||
|
||||
async function getRoadmap() {
|
||||
@@ -66,7 +78,7 @@ export function CustomRoadmap() {
|
||||
roadmapUrl.searchParams.set('secret', secret);
|
||||
}
|
||||
|
||||
const { response, error } = await httpGet<RoadmapDocument>(
|
||||
const { response, error } = await httpGet<GetRoadmapResponse>(
|
||||
roadmapUrl.toString()
|
||||
);
|
||||
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import { CircleSlash } from 'lucide-react';
|
||||
import {CircleSlash, PenSquare, Shapes} from 'lucide-react';
|
||||
|
||||
type EmptyRoadmapProps = {
|
||||
roadmapId: string;
|
||||
canManage: boolean;
|
||||
};
|
||||
|
||||
export function EmptyRoadmap(props: EmptyRoadmapProps) {
|
||||
const { roadmapId, canManage } = props;
|
||||
const editUrl = `${import.meta.env.PUBLIC_EDITOR_APP_URL}/${roadmapId}`;
|
||||
|
||||
export function EmptyRoadmap() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<CircleSlash className="mx-auto h-20 w-20 text-gray-400" />
|
||||
<h3 className="mt-4">This roadmap is currently empty.</h3>
|
||||
<h3 className="mt-2">This roadmap is currently empty.</h3>
|
||||
|
||||
{canManage && (
|
||||
<a
|
||||
href={editUrl}
|
||||
className="mt-4 rounded-md bg-gray-500 px-4 py-2 font-medium text-white hover:bg-gray-600 flex items-center"
|
||||
>
|
||||
<Shapes className="inline-block mr-2 h-4 w-4" />
|
||||
Edit Roadmap
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,6 +60,7 @@ export function PersonalRoadmapList(props: PersonalRoadmapListType) {
|
||||
|
||||
const shareSettingsModal = selectedRoadmap && (
|
||||
<ShareOptionsModal
|
||||
description={selectedRoadmap.description}
|
||||
visibility={selectedRoadmap.visibility}
|
||||
sharedFriendIds={selectedRoadmap.sharedFriendIds}
|
||||
sharedTeamMemberIds={selectedRoadmap.sharedTeamMemberIds}
|
||||
|
||||
@@ -24,6 +24,7 @@ export function ResourceProgressStats(props: ResourceProgressStatsProps) {
|
||||
<>
|
||||
{isSharing && $canManageCurrentRoadmap && $currentRoadmap && (
|
||||
<ShareOptionsModal
|
||||
description={$currentRoadmap?.description}
|
||||
visibility={$currentRoadmap?.visibility}
|
||||
teamId={$currentRoadmap?.teamId}
|
||||
roadmapId={$currentRoadmap?._id!}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { httpDelete, httpPut } from '../../lib/http';
|
||||
import { type TeamResourceConfig } from '../CreateTeam/RoadmapSelector';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import { RoadmapActionButton } from './RoadmapActionButton';
|
||||
import { Lock, Shapes } from 'lucide-react';
|
||||
|
||||
type RoadmapHeaderProps = {};
|
||||
|
||||
@@ -15,7 +16,13 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
const $canManageCurrentRoadmap = useStore(canManageCurrentRoadmap);
|
||||
const $currentRoadmap = useStore(currentRoadmap);
|
||||
|
||||
const { title, description, _id: roadmapId } = useStore(currentRoadmap) || {};
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
_id: roadmapId,
|
||||
creator,
|
||||
team,
|
||||
} = useStore(currentRoadmap) || {};
|
||||
|
||||
const [isSharing, setIsSharing] = useState(false);
|
||||
const toast = useToast();
|
||||
@@ -54,10 +61,37 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const avatarUrl = creator?.avatar
|
||||
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${creator?.avatar}`
|
||||
: '/images/default-avatar.png';
|
||||
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container relative py-5 sm:py-12">
|
||||
<div className="mb-3 mt-0 sm:mb-4">
|
||||
{creator?.name && (
|
||||
<div className="-mb-1 flex items-center gap-1.5 text-sm text-gray-500">
|
||||
<img
|
||||
alt={creator.name}
|
||||
src={avatarUrl}
|
||||
className="h-5 w-5 rounded-full"
|
||||
/>
|
||||
<span>
|
||||
Created by
|
||||
<span className="font-semibold text-gray-900">
|
||||
{creator?.name}
|
||||
</span>
|
||||
{team && (
|
||||
<>
|
||||
in
|
||||
<span className="font-semibold text-gray-900">
|
||||
{team?.name}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-3 mt-4 sm:mb-4">
|
||||
<h1 className="text-2xl font-bold sm:mb-2 sm:text-4xl">{title}</h1>
|
||||
<p className="mt-0.5 text-sm text-gray-500 sm:text-lg">
|
||||
{description}
|
||||
@@ -87,6 +121,7 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
<div className="flex items-center gap-2">
|
||||
{isSharing && $currentRoadmap && (
|
||||
<ShareOptionsModal
|
||||
description={$currentRoadmap?.description}
|
||||
visibility={$currentRoadmap?.visibility}
|
||||
teamId={$currentRoadmap?.teamId}
|
||||
roadmapId={$currentRoadmap?._id!}
|
||||
@@ -104,6 +139,25 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`${import.meta.env.PUBLIC_EDITOR_APP_URL}/${
|
||||
$currentRoadmap?._id
|
||||
}`}
|
||||
target="_blank"
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
|
||||
>
|
||||
<Shapes className="mr-1.5 h-4 w-4 stroke-[2.5]" />
|
||||
<span className="hidden sm:inline-block">Edit Roadmap</span>
|
||||
<span className="sm:hidden">Edit</span>
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setIsSharing(true)}
|
||||
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white py-1.5 pl-2 pr-2 text-xs font-medium text-black hover:border-gray-300 hover:bg-gray-300 sm:px-3 sm:text-sm"
|
||||
>
|
||||
<Lock className="mr-1.5 h-4 w-4 stroke-[2.5]" />
|
||||
Sharing
|
||||
</button>
|
||||
|
||||
<RoadmapActionButton
|
||||
onDelete={() => {
|
||||
const confirmation = window.confirm(
|
||||
@@ -116,16 +170,6 @@ export function RoadmapHeader(props: RoadmapHeaderProps) {
|
||||
|
||||
deleteResource().finally(() => null);
|
||||
}}
|
||||
onCustomize={() => {
|
||||
const editorLink = `${
|
||||
import.meta.env.PUBLIC_EDITOR_APP_URL
|
||||
}/${$currentRoadmap?._id}`;
|
||||
|
||||
window.open(editorLink, '_blank');
|
||||
}}
|
||||
onUpdateSharing={() => {
|
||||
setIsSharing(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -86,13 +86,13 @@ export function RoadmapListPage() {
|
||||
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
|
||||
)}
|
||||
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mb-6 flex flex-col justify-between gap-2 sm:flex-row sm:items-center sm:gap-0">
|
||||
<div className="flex grow items-center gap-2">
|
||||
{tabTypes.map((tab) => {
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
className={`relative flex items-center justify-center rounded-md border p-1 px-3 text-sm ${
|
||||
className={`relative flex w-full items-center justify-center whitespace-nowrap rounded-md border p-1 px-3 text-sm sm:w-auto ${
|
||||
activeTab === tab.value ? ' border-gray-400 bg-gray-200 ' : ''
|
||||
} w-full sm:w-auto`}
|
||||
onClick={() => setActiveTab(tab.value)}
|
||||
|
||||
@@ -12,8 +12,6 @@ import { pageProgressMessage } from '../../stores/page';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { RoadmapDocument } from './CreateRoadmap/CreateRoadmapModal';
|
||||
import { EmptyRoadmap } from './EmptyRoadmap';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { httpPost } from '../../lib/http';
|
||||
|
||||
type RoadmapRendererProps = {
|
||||
roadmap: RoadmapDocument;
|
||||
@@ -170,7 +168,9 @@ export function RoadmapRenderer(props: RoadmapRendererProps) {
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{hideRenderer && <EmptyRoadmap />}
|
||||
{hideRenderer && (
|
||||
<EmptyRoadmap roadmapId={roadmapId} canManage={roadmap.canManage} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,14 +2,22 @@ export function SkeletonRoadmapHeader() {
|
||||
return (
|
||||
<div className="border-b">
|
||||
<div className="container relative py-5 sm:py-12">
|
||||
<div className="mb-3 mt-0 sm:mb-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-4 w-4 animate-pulse rounded-full bg-gray-300" />
|
||||
<div className="h-5 w-5/12 animate-pulse rounded-md bg-gray-200" />
|
||||
</div>
|
||||
<div className="mb-3 mt-4 sm:mb-4">
|
||||
<div className="h-8 w-1/2 animate-pulse rounded-md bg-gray-300 sm:mb-2 sm:h-10" />
|
||||
<div className="mt-0.5 h-5 w-1/3 animate-pulse rounded-md bg-gray-200 sm:h-7" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2 sm:gap-0">
|
||||
<div className="h-7 w-[35.04px] sm:w-32 animate-pulse rounded-md bg-gray-300 sm:h-8" />
|
||||
<div className="h-7 w-[32px] sm:w-[89.73px] animate-pulse rounded-md bg-gray-300 sm:h-8" />
|
||||
<div className="h-7 w-[35.04px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-32" />
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-7 w-[60.52px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[139.71px]" />
|
||||
<div className="h-7 w-[71.48px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[100.34px]" />
|
||||
<div className="h-7 w-[32px] animate-pulse rounded-md bg-gray-300 sm:h-8 sm:w-[89.73px]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-0 mt-4 rounded-md border-0 sm:-mb-[65px] sm:mt-7 sm:border">
|
||||
|
||||
@@ -42,13 +42,7 @@ const {
|
||||
{
|
||||
showCreateRoadmap && (
|
||||
<li>
|
||||
<CreateRoadmapButton
|
||||
client:load
|
||||
className='min-h-[54px]'
|
||||
type={
|
||||
heading.toLowerCase().indexOf('role') > -1 ? 'role' : 'skill'
|
||||
}
|
||||
/>
|
||||
<CreateRoadmapButton client:load className='min-h-[54px]' />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,20 +68,30 @@ import Icon from './AstroIcon.astro';
|
||||
<a href='/privacy' class='hover:text-white'>Privacy</a>
|
||||
<span class='mx-1.5'>·</span>
|
||||
<a
|
||||
aria-label="Subscribe to YouTube channel"
|
||||
aria-label='Write us an email'
|
||||
href='mailto:info@roadmap.sh'
|
||||
class='hover:text-white'
|
||||
>
|
||||
<AstroIcon icon='letter' class='inline-block h-5 w-5' />
|
||||
</a>
|
||||
<a
|
||||
aria-label='Subscribe to YouTube channel'
|
||||
href='https://youtube.com/theroadmap?sub_confirmation=1'
|
||||
target='_blank'
|
||||
class='hover:text-white'
|
||||
class='ml-2 hover:text-white'
|
||||
>
|
||||
<AstroIcon icon='youtube' class='inline-block h-5 w-5' />
|
||||
</a>
|
||||
<a
|
||||
aria-label="Follow on Twitter"
|
||||
aria-label='Follow on Twitter'
|
||||
href='https://twitter.com/roadmapsh'
|
||||
target='_blank'
|
||||
class='ml-2 hover:text-white'
|
||||
>
|
||||
<AstroIcon icon='twitter-fill' class='inline-block h-5 w-5 fill-current' />
|
||||
<AstroIcon
|
||||
icon='twitter-fill'
|
||||
class='inline-block h-5 w-5 fill-current'
|
||||
/>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { CheckIcon } from '../ReactIcons/CheckIcon';
|
||||
import { TeamAnnouncement } from '../TeamAnnouncement';
|
||||
|
||||
type EmptyProgressProps = {
|
||||
title?: string;
|
||||
message?: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export function EmptyProgress(props: EmptyProgressProps) {
|
||||
const {
|
||||
title = 'Start learning ..',
|
||||
message = 'Your progress and favorite roadmaps will show up here.',
|
||||
} = props;
|
||||
const {
|
||||
title = 'Start learning ..',
|
||||
message = 'Your progress and favorite roadmaps will show up here.',
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-full flex-col items-start sm:items-center justify-center py-6">
|
||||
<h2 className={'mb-1 flex items-center text-lg sm:text-2xl text-gray-200'}>
|
||||
<CheckIcon additionalClasses='mr-2 top-[0.5px] w-[16px] h-[16px] sm:w-[20px] sm:h-[20px]' />
|
||||
Start learning ..
|
||||
</h2>
|
||||
<p className={'text-gray-400 text-sm sm:text-base'}>{message}</p>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="relative flex min-h-full flex-col items-start justify-center py-6 sm:items-center">
|
||||
<h2
|
||||
className={'mb-1.5 flex items-center text-lg text-gray-200 sm:text-2xl'}
|
||||
>
|
||||
<CheckIcon additionalClasses="mr-2 top-[0.5px] w-[16px] h-[16px] sm:w-[20px] sm:h-[20px]" />
|
||||
{title}
|
||||
</h2>
|
||||
<p className={'text-sm text-gray-400 sm:text-base'}>{message}</p>
|
||||
|
||||
<p className="mt-5">
|
||||
<TeamAnnouncement />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { EmptyProgress } from './EmptyProgress';
|
||||
import { httpGet } from '../../lib/http';
|
||||
import { HeroRoadmaps } from './HeroRoadmaps';
|
||||
import { HeroRoadmaps, type HeroTeamRoadmaps } from './HeroRoadmaps';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import type { AllowedMemberRoles } from '../ShareOptions/ShareTeamMemberList.tsx';
|
||||
|
||||
export type UserProgressResponse = {
|
||||
resourceId: string;
|
||||
@@ -15,6 +16,11 @@ export type UserProgressResponse = {
|
||||
total: number;
|
||||
updatedAt: Date;
|
||||
isCustomResource: boolean;
|
||||
team?: {
|
||||
name: string;
|
||||
id: string;
|
||||
role: AllowedMemberRoles;
|
||||
};
|
||||
}[];
|
||||
|
||||
function renderProgress(progressList: UserProgressResponse) {
|
||||
@@ -114,25 +120,43 @@ export function FavoriteRoadmaps() {
|
||||
}
|
||||
|
||||
const hasProgress = progress?.length > 0;
|
||||
const customRoadmaps = progress?.filter((p) => p.isCustomResource);
|
||||
const customRoadmaps = progress?.filter(
|
||||
(p) => p.isCustomResource && !p.team?.name
|
||||
);
|
||||
const defaultRoadmaps = progress?.filter((p) => !p.isCustomResource);
|
||||
const teamRoadmaps: HeroTeamRoadmaps = progress
|
||||
?.filter((p) => p.isCustomResource && p.team?.name)
|
||||
.reduce((acc: HeroTeamRoadmaps, curr) => {
|
||||
const currTeam = curr.team!;
|
||||
if (!acc[currTeam.name]) {
|
||||
acc[currTeam.name] = [];
|
||||
}
|
||||
|
||||
acc[currTeam.name].push(curr);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex min-h-[192px] bg-gradient-to-b transition-opacity duration-500 sm:min-h-[280px] opacity-${containerOpacity} ${
|
||||
hasProgress && `border-t border-t-[#1e293c]`
|
||||
}`}
|
||||
className={`transition-opacity duration-500 opacity-${containerOpacity}`}
|
||||
>
|
||||
<div className="container min-h-full">
|
||||
{!isLoading && progress?.length == 0 && <EmptyProgress />}
|
||||
{hasProgress && (
|
||||
<HeroRoadmaps
|
||||
showCustomRoadmaps={true}
|
||||
customRoadmaps={customRoadmaps}
|
||||
progress={defaultRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`flex min-h-[192px] bg-gradient-to-b sm:min-h-[280px] ${
|
||||
hasProgress && `border-t border-t-[#1e293c]`
|
||||
}`}
|
||||
>
|
||||
<div className="container min-h-full">
|
||||
{!isLoading && progress?.length == 0 && <EmptyProgress />}
|
||||
{hasProgress && (
|
||||
<HeroRoadmaps
|
||||
teamRoadmaps={teamRoadmaps}
|
||||
customRoadmaps={customRoadmaps}
|
||||
progress={defaultRoadmaps}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,11 @@ 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';
|
||||
import { MapIcon, Users2 } from 'lucide-react';
|
||||
import { CreateRoadmapButton } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapButton';
|
||||
import { CreateRoadmapModal } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { useState } from 'react';
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { TeamAnnouncement } from '../TeamAnnouncement';
|
||||
|
||||
type ProgressRoadmapProps = {
|
||||
url: string;
|
||||
@@ -55,7 +56,7 @@ function HeroRoadmap(props: ProgressRoadmapProps) {
|
||||
type ProgressTitleProps = {
|
||||
icon: any;
|
||||
isLoading?: boolean;
|
||||
title: string;
|
||||
title: string | ReactNode;
|
||||
};
|
||||
|
||||
export function HeroTitle(props: ProgressTitleProps) {
|
||||
@@ -73,22 +74,39 @@ export function HeroTitle(props: ProgressTitleProps) {
|
||||
</p>
|
||||
);
|
||||
}
|
||||
export type HeroTeamRoadmaps = Record<string, UserProgressResponse>;
|
||||
|
||||
type ProgressListProps = {
|
||||
progress: UserProgressResponse;
|
||||
customRoadmaps: UserProgressResponse;
|
||||
teamRoadmaps?: HeroTeamRoadmaps;
|
||||
isLoading?: boolean;
|
||||
};
|
||||
|
||||
export function HeroRoadmaps(props: ProgressListProps) {
|
||||
const { progress, isLoading = false, customRoadmaps } = props;
|
||||
const {
|
||||
teamRoadmaps = {},
|
||||
progress,
|
||||
isLoading = false,
|
||||
customRoadmaps,
|
||||
} = props;
|
||||
|
||||
const [isCreatingRoadmap, setIsCreatingRoadmap] = useState(false);
|
||||
const [creatingRoadmapTeamId, setCreatingRoadmapTeamId] = useState<string>();
|
||||
|
||||
return (
|
||||
<div className="relative pb-12 pt-4 sm:pt-7">
|
||||
<p className="mb-7 mt-2 text-sm">
|
||||
<TeamAnnouncement />
|
||||
</p>
|
||||
{isCreatingRoadmap && (
|
||||
<CreateRoadmapModal onClose={() => setIsCreatingRoadmap(false)} />
|
||||
<CreateRoadmapModal
|
||||
teamId={creatingRoadmapTeamId}
|
||||
onClose={() => {
|
||||
setIsCreatingRoadmap(false);
|
||||
setCreatingRoadmapTeamId(undefined);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
<HeroTitle
|
||||
@@ -164,6 +182,83 @@ export function HeroRoadmaps(props: ProgressListProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{Object.keys(teamRoadmaps).map((teamName) => {
|
||||
const currentTeam: UserProgressResponse[0]['team'] =
|
||||
teamRoadmaps?.[teamName]?.[0]?.team;
|
||||
const roadmapsList = teamRoadmaps[teamName].filter(
|
||||
(roadmap) => !!roadmap.resourceTitle
|
||||
);
|
||||
const canManageTeam = ['admin', 'manager'].includes(currentTeam?.role!);
|
||||
|
||||
return (
|
||||
<div className="mt-5" key={teamName}>
|
||||
{
|
||||
<HeroTitle
|
||||
icon={<Users2 className="mr-1.5 h-[14px] w-[14px]" />}
|
||||
title={
|
||||
<>
|
||||
Team{' '}
|
||||
<a
|
||||
className="mx-1 font-medium underline underline-offset-2 transition-colors hover:text-gray-300"
|
||||
href={`/team/progress?t=${currentTeam?.id}`}
|
||||
>
|
||||
{teamName}
|
||||
</a>
|
||||
Roadmaps
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
||||
{roadmapsList.length === 0 && (
|
||||
<p className="rounded-md border border-dashed border-gray-800 p-2 text-sm text-gray-600">
|
||||
Team does not have any roadmaps yet.{' '}
|
||||
{canManageTeam && (
|
||||
<button
|
||||
className="text-gray-500 underline underline-offset-2 hover:text-gray-400"
|
||||
onClick={() => {
|
||||
setCreatingRoadmapTeamId(currentTeam?.id);
|
||||
setIsCreatingRoadmap(true);
|
||||
}}
|
||||
>
|
||||
Create one!
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{roadmapsList.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-3">
|
||||
{roadmapsList.map((customRoadmap) => {
|
||||
return (
|
||||
<HeroRoadmap
|
||||
key={customRoadmap.resourceId}
|
||||
resourceId={customRoadmap.resourceId}
|
||||
resourceType={'roadmap'}
|
||||
resourceTitle={customRoadmap.resourceTitle}
|
||||
percentageDone={
|
||||
((customRoadmap.skipped + customRoadmap.done) /
|
||||
customRoadmap.total) *
|
||||
100
|
||||
}
|
||||
url={`/r?id=${customRoadmap.resourceId}`}
|
||||
allowFavorite={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{canManageTeam && (
|
||||
<CreateRoadmapButton
|
||||
teamId={currentTeam?.id}
|
||||
text="Create Team Roadmap"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
---
|
||||
import { FavoriteRoadmaps } from './FavoriteRoadmaps';
|
||||
import {TeamAnnouncement} from "../TeamAnnouncement";
|
||||
---
|
||||
|
||||
<div class='relative min-h-auto min-h-[192px] sm:min-h-[281px] border-b border-b-[#1e293c]'>
|
||||
<div
|
||||
class='min-h-auto relative min-h-[192px] border-b border-b-[#1e293c] sm:min-h-[281px] transition-all'
|
||||
>
|
||||
<div
|
||||
class='container px-6 py-6 pb-14 text-left sm:px-0 sm:py-20 sm:text-center transition-opacity duration-300'
|
||||
class='container px-5 py-6 pb-14 text-left transition-opacity duration-300 sm:px-0 sm:py-20 sm:text-center'
|
||||
id='hero-text'
|
||||
>
|
||||
<p class='-mt-4 sm:-mt-10 mb-7'>
|
||||
<TeamAnnouncement />
|
||||
</p>
|
||||
|
||||
<h1
|
||||
class='mb-2 bg-gradient-to-b from-amber-50 to-purple-500 bg-clip-text text-2xl font-bold text-transparent sm:mb-4 sm:text-5xl'
|
||||
>
|
||||
@@ -24,5 +31,5 @@ import { FavoriteRoadmaps } from './FavoriteRoadmaps';
|
||||
their career.
|
||||
</p>
|
||||
</div>
|
||||
<FavoriteRoadmaps client:only="react" />
|
||||
<FavoriteRoadmaps client:only='react' />
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,14 @@ export function AccountDropdownList(props: AccountDropdownListProps) {
|
||||
Friends
|
||||
</a>
|
||||
</li>
|
||||
<li className="px-1">
|
||||
<a
|
||||
href="/account/roadmaps"
|
||||
className="block rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
>
|
||||
Roadmaps
|
||||
</a>
|
||||
</li>
|
||||
<li className="px-1">
|
||||
<button
|
||||
className="group flex w-full items-center justify-between rounded pl-4 pr-2 py-2 text-sm font-medium text-slate-100 hover:bg-slate-700"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import Icon from '../AstroIcon.astro';
|
||||
import { AccountDropdown } from './AccountDropdown';
|
||||
---
|
||||
|
||||
<div class='bg-slate-900 py-5 text-white sm:py-8'>
|
||||
<nav class='container flex items-center justify-between'>
|
||||
<a
|
||||
@@ -27,8 +26,25 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
<a href='/questions' class='text-gray-400 hover:text-white'>Questions</a
|
||||
>
|
||||
</li>
|
||||
<li class='hidden lg:inline'>
|
||||
<a href='/guides' class='text-gray-400 hover:text-white'>Guides</a>
|
||||
<li>
|
||||
<a href='/teams' class='group relative text-blue-300 hover:text-white'>
|
||||
Teams
|
||||
<span
|
||||
class='ml-0.5 hidden rounded-sm border-black bg-blue-300 px-1 py-0.5 text-xs font-semibold uppercase text-black group-hover:bg-white md:inline'
|
||||
>
|
||||
New
|
||||
</span>
|
||||
|
||||
<span class='inline md:hidden absolute -right-[11px] top-0'>
|
||||
<span class='relative flex h-2 w-2'>
|
||||
<span
|
||||
class='absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75'
|
||||
></span>
|
||||
<span class='relative inline-flex h-2 w-2 rounded-full bg-sky-500'
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<kbd
|
||||
@@ -45,7 +61,7 @@ import { AccountDropdown } from './AccountDropdown';
|
||||
<a href='/login' class='text-gray-400 hover:text-white'>Login</a>
|
||||
</li>
|
||||
<li>
|
||||
<AccountDropdown client:only="react" />
|
||||
<AccountDropdown client:only='react' />
|
||||
|
||||
<a
|
||||
data-guest-required
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { CheckCircle, Copy } from 'lucide-react';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import { cn } from '../../lib/classname';
|
||||
|
||||
type CopyRoadmapLinkProps = {
|
||||
roadmapId: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CopyRoadmapLink(props: CopyRoadmapLinkProps) {
|
||||
const { roadmapId, onClose } = props;
|
||||
|
||||
const shareLink = `${
|
||||
import.meta.env.PUBLIC_ROADMAP_WEB_URL
|
||||
}/r?id=${roadmapId}`;
|
||||
const { copyText, isCopied } = useCopyText();
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col justify-center">
|
||||
<div className="mt-5 flex grow flex-col items-center justify-center gap-1.5">
|
||||
<CheckCircle className="h-14 w-14 text-green-500" />
|
||||
<h3 className="text-xl font-medium">Sharing Settings Updated</h3>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="mt-6 w-full rounded-md border bg-gray-50 p-2 px-2.5 text-gray-700 focus:outline-none"
|
||||
value={shareLink}
|
||||
readOnly
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(shareLink);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
You can share the above link with anyone who has access
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex flex-col items-center justify-end gap-2">
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-1.5 rounded bg-black px-4 py-2.5 text-sm font-medium text-white hover:opacity-80',
|
||||
isCopied && 'bg-green-300 text-green-800'
|
||||
)}
|
||||
disabled={isCopied}
|
||||
onClick={() => {
|
||||
copyText(shareLink);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5 stroke-[2.5]" />
|
||||
{isCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-1.5 rounded border border-black px-4 py-2 text-sm font-medium hover:bg-gray-100'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useCallback, useState } from 'react';
|
||||
import { type ReactNode, useCallback, useState, useMemo } from 'react';
|
||||
import { Globe2, Loader2, Lock } from 'lucide-react';
|
||||
import { type ListFriendsResponse, ShareFriendList } from './ShareFriendList';
|
||||
import { TransferToTeamList } from './TransferToTeamList';
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
ShareTeamMemberList,
|
||||
type TeamMemberList,
|
||||
} from './ShareTeamMemberList';
|
||||
import { CopyRoadmapLink } from './CopyRoadmapLink';
|
||||
import { ShareSuccess } from './ShareSuccess';
|
||||
import { useToast } from '../../hooks/use-toast';
|
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import { httpPatch } from '../../lib/http';
|
||||
@@ -28,6 +28,7 @@ type ShareOptionsModalProps = {
|
||||
sharedTeamMemberIds?: string[];
|
||||
teamId?: string;
|
||||
roadmapId?: string;
|
||||
description?: string;
|
||||
|
||||
onShareSettingsUpdate: OnShareSettingsUpdate;
|
||||
};
|
||||
@@ -41,6 +42,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
sharedFriendIds: defaultSharedFriendIds = [],
|
||||
teamId,
|
||||
onShareSettingsUpdate,
|
||||
description,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
@@ -49,9 +51,13 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
const [isSettingsUpdated, setIsSettingsUpdated] = useState(false);
|
||||
const [friends, setFriends] = useState<ListFriendsResponse>([]);
|
||||
const [teams, setTeams] = useState<UserTeamItem[]>([]);
|
||||
const [members, setMembers] = useState<TeamMemberList[]>([]);
|
||||
|
||||
// Using global team members loading state to avoid glitchy UI when switching between teams
|
||||
const [isTeamMembersLoading, setIsTeamMembersLoading] = useState(false);
|
||||
const membersCache = useMemo(() => new Map<string, TeamMemberList[]>(), []);
|
||||
|
||||
const [visibility, setVisibility] = useState(defaultVisibility);
|
||||
const [isDiscoverable, setIsDiscoverable] = useState(false);
|
||||
const [sharedTeamMemberIds, setSharedTeamMemberIds] = useState<string[]>(
|
||||
defaultSharedMemberIds
|
||||
);
|
||||
@@ -104,6 +110,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
visibility,
|
||||
sharedFriendIds,
|
||||
sharedTeamMemberIds,
|
||||
isDiscoverable,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -118,7 +125,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
};
|
||||
|
||||
const handleTransferToTeam = useCallback(
|
||||
async (teamId: string) => {
|
||||
async (teamId: string, sharedTeamMemberIds: string[]) => {
|
||||
if (!roadmapId) {
|
||||
return;
|
||||
}
|
||||
@@ -128,6 +135,8 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-transfer-roadmap/${roadmapId}`,
|
||||
{
|
||||
teamId,
|
||||
sharedTeamMemberIds,
|
||||
isDiscoverable,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -149,7 +158,12 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
wrapperClassName="max-w-lg"
|
||||
bodyClassName="p-4 flex flex-col"
|
||||
>
|
||||
<CopyRoadmapLink roadmapId={roadmapId!} onClose={onClose} />
|
||||
<ShareSuccess
|
||||
visibility={visibility}
|
||||
roadmapId={roadmapId!}
|
||||
description={description}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -163,7 +177,7 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
onClose();
|
||||
}}
|
||||
wrapperClassName="max-w-3xl"
|
||||
bodyClassName="p-4 flex flex-col min-h-[400px]"
|
||||
bodyClassName="p-4 flex flex-col min-h-[440px]"
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-xl font-semibold">Update Sharing Settings</h3>
|
||||
@@ -225,14 +239,6 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
setSharedFriendIds={setSharedFriendIds}
|
||||
/>
|
||||
)}
|
||||
{canTransferRoadmap && (
|
||||
<TransferToTeamList
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
selectedTeamId={selectedTeamId}
|
||||
setSelectedTeamId={setSelectedTeamId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* For Team Roadmap */}
|
||||
{visibility === 'team' && teamId && (
|
||||
@@ -240,12 +246,57 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
teamId={teamId}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
members={members}
|
||||
setMembers={setMembers}
|
||||
membersCache={membersCache}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canTransferRoadmap && (
|
||||
<>
|
||||
<TransferToTeamList
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
selectedTeamId={selectedTeamId}
|
||||
setSelectedTeamId={setSelectedTeamId}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
onTeamChange={() => {
|
||||
setSharedTeamMemberIds([]);
|
||||
}}
|
||||
/>
|
||||
{selectedTeamId && (
|
||||
<>
|
||||
<hr className="-mx-4 my-4" />
|
||||
<div className="mb-4">
|
||||
<ShareTeamMemberList
|
||||
title="Select who can access this roadmap. You can change this later."
|
||||
teamId={selectedTeamId!}
|
||||
sharedTeamMemberIds={sharedTeamMemberIds}
|
||||
setSharedTeamMemberIds={setSharedTeamMemberIds}
|
||||
membersCache={membersCache}
|
||||
isTeamMembersLoading={isTeamMembersLoading}
|
||||
setIsTeamMembersLoading={setIsTeamMembersLoading}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibility !== 'me' && (
|
||||
<>
|
||||
<hr className="-mx-4 my-4" />
|
||||
<div className="mb-2">
|
||||
<DiscoveryCheckbox
|
||||
isDiscoverable={isDiscoverable}
|
||||
setIsDiscoverable={setIsDiscoverable}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex items-center justify-between gap-1.5">
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 rounded-md border px-3.5 py-1.5 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-75"
|
||||
@@ -255,17 +306,23 @@ export function ShareOptionsModal(props: ShareOptionsModalProps) {
|
||||
Close
|
||||
</button>
|
||||
|
||||
{canTransferRoadmap ? (
|
||||
{canTransferRoadmap && (
|
||||
<UpdateAction
|
||||
disabled={isUpdateDisabled || isLoading}
|
||||
disabled={
|
||||
isUpdateDisabled || isLoading || sharedTeamMemberIds.length === 0
|
||||
}
|
||||
onClick={() => {
|
||||
handleTransferToTeam(selectedTeamId!).then(() => null);
|
||||
handleTransferToTeam(selectedTeamId!, sharedTeamMemberIds).then(
|
||||
() => null
|
||||
);
|
||||
}}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
Transfer
|
||||
</UpdateAction>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{!canTransferRoadmap && (
|
||||
<UpdateAction
|
||||
disabled={isUpdateDisabled || isLoading}
|
||||
onClick={() => {
|
||||
@@ -309,3 +366,25 @@ function UpdateAction(props: {
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type DiscoveryCheckboxProps = {
|
||||
isDiscoverable: boolean;
|
||||
setIsDiscoverable: (isDiscoverable: boolean) => void;
|
||||
};
|
||||
|
||||
function DiscoveryCheckbox(props: DiscoveryCheckboxProps) {
|
||||
const { isDiscoverable, setIsDiscoverable } = props;
|
||||
|
||||
return (
|
||||
<label className="group flex items-center gap-1.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDiscoverable}
|
||||
onChange={(e) => setIsDiscoverable(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-gray-500 group-hover:text-gray-700">
|
||||
Include on discovery page (when launched)
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
115
src/components/ShareOptions/ShareSuccess.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { CheckCircle, Copy, Facebook, Linkedin, Twitter } from 'lucide-react';
|
||||
import { useCopyText } from '../../hooks/use-copy-text';
|
||||
import { cn } from '../../lib/classname';
|
||||
import type { AllowedRoadmapVisibility } from '../CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
|
||||
type ShareSuccessProps = {
|
||||
roadmapId: string;
|
||||
onClose: () => void;
|
||||
visibility: AllowedRoadmapVisibility;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export function ShareSuccess(props: ShareSuccessProps) {
|
||||
const { roadmapId, onClose, description, visibility } = props;
|
||||
|
||||
const baseUrl = import.meta.env.DEV
|
||||
? 'http://localhost:3000'
|
||||
: 'https://roadmap.sh';
|
||||
const shareLink = `${baseUrl}/r?id=${roadmapId}`;
|
||||
|
||||
const { copyText, isCopied } = useCopyText();
|
||||
|
||||
const socialShareLinks = [
|
||||
{
|
||||
title: 'Twitter',
|
||||
href: `https://twitter.com/intent/tweet?text=${description}&url=${shareLink}`,
|
||||
icon: Twitter,
|
||||
},
|
||||
{
|
||||
title: 'Facebook',
|
||||
href: `https://www.facebook.com/sharer/sharer.php?quote=${description}&u=${shareLink}`,
|
||||
icon: Facebook,
|
||||
},
|
||||
{
|
||||
title: 'Linkedin',
|
||||
href: `https://www.linkedin.com/sharing/share-offsite/?url=${shareLink}`,
|
||||
icon: Linkedin,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col justify-center">
|
||||
<div className="mt-5 flex grow flex-col items-center justify-center gap-1.5">
|
||||
<CheckCircle className="h-14 w-14 text-green-500" />
|
||||
<h3 className="text-xl font-medium">Sharing Settings Updated</h3>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
className="mt-6 w-full rounded-md border bg-gray-50 p-2 px-2.5 text-gray-700 focus:outline-none"
|
||||
value={shareLink}
|
||||
readOnly
|
||||
onClick={(e) => {
|
||||
e.currentTarget.select();
|
||||
copyText(shareLink);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-sm text-gray-400">
|
||||
You can share the above link with anyone who has access
|
||||
</p>
|
||||
|
||||
{visibility === 'public' && (
|
||||
<>
|
||||
<div className="-mx-4 mt-4 flex items-center gap-1.5">
|
||||
<span className="h-px grow bg-gray-300" />
|
||||
<span className="text-sm uppercase text-gray-600">Or</span>
|
||||
<span className="h-px grow bg-gray-300" />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Share on</span>
|
||||
<ul className="flex items-center gap-1.5">
|
||||
{socialShareLinks.map((socialShareLink) => (
|
||||
<li key={socialShareLink.title}>
|
||||
<a
|
||||
href={socialShareLink.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-8 w-8 items-center justify-center gap-1.5 rounded-md border bg-gray-50 text-sm text-gray-700 hover:bg-gray-100 focus:outline-none"
|
||||
>
|
||||
<socialShareLink.icon className="h-4 w-4" />
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-col items-center justify-end gap-2">
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-1.5 rounded bg-black px-4 py-2.5 text-sm font-medium text-white hover:opacity-80',
|
||||
isCopied && 'bg-green-300 text-green-800'
|
||||
)}
|
||||
disabled={isCopied}
|
||||
onClick={() => {
|
||||
copyText(shareLink);
|
||||
}}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5 stroke-[2.5]" />
|
||||
{isCopied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-1.5 rounded border border-black px-4 py-2 text-sm font-medium hover:bg-gray-100'
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,27 +33,31 @@ export interface TeamMemberList extends TeamMemberDocument {
|
||||
|
||||
type ShareTeamMemberListProps = {
|
||||
teamId: string;
|
||||
setMembers: (members: TeamMemberList[]) => void;
|
||||
members: TeamMemberList[];
|
||||
title?: string;
|
||||
sharedTeamMemberIds: string[];
|
||||
setSharedTeamMemberIds: (sharedTeamMemberIds: string[]) => void;
|
||||
|
||||
membersCache: Map<string, TeamMemberList[]>;
|
||||
isTeamMembersLoading: boolean;
|
||||
setIsTeamMembersLoading: (isLoading: boolean) => void;
|
||||
};
|
||||
|
||||
export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
|
||||
const {
|
||||
setMembers,
|
||||
members,
|
||||
teamId,
|
||||
title = 'Select Members',
|
||||
sharedTeamMemberIds,
|
||||
setSharedTeamMemberIds,
|
||||
teamId,
|
||||
|
||||
membersCache,
|
||||
isTeamMembersLoading: isLoading,
|
||||
setIsTeamMembersLoading: setIsLoading,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
async function loadTeamMembers() {
|
||||
if (members?.length > 0) {
|
||||
if (membersCache.has(teamId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,21 +71,23 @@ export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMembers(response);
|
||||
const joinedMembers =
|
||||
response?.filter((member) => member.status === 'joined') || [];
|
||||
membersCache.set(teamId, joinedMembers);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadTeamMembers().finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
}, [teamId]);
|
||||
|
||||
const loadingMembers = isLoading && (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-2.5">
|
||||
{[...Array(3)].map((_, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex min-h-[62px] animate-pulse items-center gap-2 rounded-md border p-2"
|
||||
className="flex min-h-[66px] animate-pulse items-center gap-2 rounded-md border p-2"
|
||||
>
|
||||
<div className="h-8 w-8 shrink-0 rounded-full bg-gray-200" />
|
||||
<div className="inline-grid w-full">
|
||||
@@ -93,11 +99,13 @@ export function ShareTeamMemberList(props: ShareTeamMemberListProps) {
|
||||
</ul>
|
||||
);
|
||||
|
||||
const members = membersCache.get(teamId) || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{(members.length > 0 || isLoading) && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-sm">Select Members</p>
|
||||
<p className="text-sm">{title}</p>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
|
||||
@@ -11,10 +11,22 @@ type TransferToTeamListProps = {
|
||||
|
||||
selectedTeamId: string | null;
|
||||
setSelectedTeamId: (teamId: string | null) => void;
|
||||
|
||||
isTeamMembersLoading: boolean;
|
||||
setIsTeamMembersLoading: (isLoading: boolean) => void;
|
||||
onTeamChange: (teamId: string | null) => void;
|
||||
};
|
||||
|
||||
export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
const { teams, setTeams, selectedTeamId, setSelectedTeamId } = props;
|
||||
const {
|
||||
teams,
|
||||
setTeams,
|
||||
selectedTeamId,
|
||||
setSelectedTeamId,
|
||||
isTeamMembersLoading,
|
||||
setIsTeamMembersLoading,
|
||||
onTeamChange,
|
||||
} = props;
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
@@ -73,11 +85,17 @@ export function TransferToTeamList(props: TransferToTeamListProps) {
|
||||
<li key={team._id}>
|
||||
<button
|
||||
className={cn(
|
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5',
|
||||
'relative flex w-full items-center gap-2.5 rounded-lg border p-2.5 disabled:cursor-not-allowed disabled:opacity-70',
|
||||
isSelected && 'border-gray-500 bg-gray-100 text-black'
|
||||
)}
|
||||
disabled={isTeamMembersLoading}
|
||||
onClick={() => {
|
||||
setSelectedTeamId(team._id);
|
||||
if (isSelected) {
|
||||
setSelectedTeamId(null);
|
||||
} else {
|
||||
setSelectedTeamId(team._id);
|
||||
}
|
||||
onTeamChange(team._id);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
|
||||
16
src/components/TeamAnnouncement.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
type TeamAnnouncementProps = {};
|
||||
|
||||
export function TeamAnnouncement(props: TeamAnnouncementProps) {
|
||||
return (
|
||||
<a
|
||||
className="rounded-md border border-dashed border-purple-700 px-3 py-1.5 text-purple-400 transition-colors hover:border-gray-700 hover:text-white"
|
||||
href="/teams"
|
||||
>
|
||||
<span className="relative -top-[0.5px] mr-1 text-xs font-semibold uppercase text-white">
|
||||
New
|
||||
</span>{' '}
|
||||
<span className={'hidden sm:inline'}>Announcing roadmaps for teams. <span className='font-semibold'>Learn more!</span></span>
|
||||
<span className={'inline sm:hidden'}>Announcing roadmaps for teams!</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
164
src/components/TeamMarketing/TeamDemo.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import { fireTeamCreationClick } from './TeamHeroBanner.tsx';
|
||||
|
||||
const demoItems = [
|
||||
{
|
||||
title: 'Roadmap Editor',
|
||||
description:
|
||||
'<span class="font-semibold">Powerful editor</span> to create custom roadmaps and other trackable documents',
|
||||
image: '/images/team-promo/roadmap-editor.png',
|
||||
},
|
||||
{
|
||||
title: 'Invite Members',
|
||||
description:
|
||||
'Invite your <span class="font-semibold">team members and assign roles</span>',
|
||||
image: '/images/team-promo/invite-members.png',
|
||||
},
|
||||
{
|
||||
title: 'Track Progress',
|
||||
description:
|
||||
'You and your team can <span class="font-semibold">track progress</span> on the roadmaps',
|
||||
image: '/images/team-promo/update-progress.png',
|
||||
},
|
||||
{
|
||||
title: 'Team Dashboard',
|
||||
description:
|
||||
'Keep an eye on the team progress through <span class="font-semibold">team dashboards</span>',
|
||||
image: '/images/team-promo/team-dashboard.png',
|
||||
},
|
||||
{
|
||||
title: 'Roadmaps and Documents',
|
||||
description:
|
||||
'Create as many <span class="font-semibold">roadmaps or trackable documents</span> as you want',
|
||||
image: '/images/team-promo/many-roadmaps.png',
|
||||
},
|
||||
{
|
||||
title: 'Community Roadmaps',
|
||||
description:
|
||||
'Create custom roadmaps or customize <span class="font-semibold">community roadmaps</span> to fit your needs',
|
||||
image: '/images/team-promo/our-roadmaps.png',
|
||||
},
|
||||
{
|
||||
title: 'Sharing Settings',
|
||||
description:
|
||||
'Share a roadmap or trackable document with <span class="font-semibold">everyone or specific people</span>',
|
||||
image: '/images/team-promo/sharing-settings.png',
|
||||
},
|
||||
{
|
||||
title: 'More Coming Soon!',
|
||||
description:
|
||||
'<span class="font-semibold">We have a lot more coming soon!</span>',
|
||||
},
|
||||
];
|
||||
|
||||
export function TeamDemo() {
|
||||
const [hasViewed, setHasViewed] = useState<number[]>([0]);
|
||||
const [activeItem, setActiveItem] = useState(demoItems[0]);
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAuthenticated(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="hidden border-t py-12 sm:block">
|
||||
<div className="container">
|
||||
<h2 className="mb-2 text-3xl font-bold">See how it works</h2>
|
||||
<p>Here is a sneak peek of what you can do today (more coming soon!)</p>
|
||||
|
||||
<div className="relative mt-7 flex flex-row items-center gap-2.5">
|
||||
{demoItems.map((item, counter) => {
|
||||
const isActive = item === activeItem;
|
||||
const hasAlreadyViewed = hasViewed.includes(counter);
|
||||
|
||||
if (!isActive) {
|
||||
return (
|
||||
<span key={item.title} className="relative flex items-center">
|
||||
<span
|
||||
onClick={() => {
|
||||
setHasViewed([...hasViewed, counter]);
|
||||
setActiveItem(item);
|
||||
}}
|
||||
className={cn('z-50 cursor-pointer rounded-full p-[6px]', {
|
||||
'bg-black': item === activeItem,
|
||||
'bg-gray-300 hover:bg-gray-400': item !== activeItem,
|
||||
})}
|
||||
/>
|
||||
{!hasAlreadyViewed && (
|
||||
<span className="pointer-events-none absolute inline-flex h-full w-full animate-ping rounded-full bg-gray-400 opacity-75"></span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={item.title}
|
||||
className=" rounded-full bg-black px-3 py-0.5 text-sm text-white"
|
||||
>
|
||||
{activeItem.title}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-4 overflow-hidden rounded-xl border border-gray-300">
|
||||
<div className="p-3">
|
||||
<p
|
||||
className="text-base text-black"
|
||||
dangerouslySetInnerHTML={{ __html: activeItem.description }}
|
||||
/>
|
||||
</div>
|
||||
{activeItem.image && (
|
||||
<img
|
||||
className="rounded-b-xl border-t"
|
||||
src={activeItem.image}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
{!activeItem.image && (
|
||||
<div className="bg-gray-50 py-4 pl-3">
|
||||
<p className="mb-3">
|
||||
Register your team now and help us shape the future of teams in
|
||||
roadmap.sh!
|
||||
</p>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<a
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
if (isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('authRedirect', '/team/new');
|
||||
}}
|
||||
href={isAuthenticated ? '/team/new' : '/signup'}
|
||||
className="inline-flex items-center justify-center rounded-lg border border-transparent bg-purple-600 px-5 py-2 text-base font-medium text-white hover:bg-purple-700"
|
||||
>
|
||||
Create your Team
|
||||
</a>
|
||||
{!isAuthenticated && (
|
||||
<span className="ml-1 text-base">
|
||||
or
|
||||
<a
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
localStorage.setItem('authRedirect', '/team/new');
|
||||
}}
|
||||
href="/login"
|
||||
className="text-purple-600 underline hover:text-purple-700"
|
||||
>
|
||||
Login to your account
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/components/TeamMarketing/TeamHeroBanner.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { CheckCircle, CheckCircle2, CheckIcon } from 'lucide-react';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const featureList = [
|
||||
'Create custom roadmaps for your team',
|
||||
"Plan, track and document your team's skills and growth",
|
||||
'Invite your team members',
|
||||
"Get insights on your team's skills and growth",
|
||||
];
|
||||
|
||||
export function fireTeamCreationClick() {
|
||||
window.fireEvent({
|
||||
category: 'FeatureClick',
|
||||
action: `Pages / Teams`,
|
||||
label: 'Create your Team',
|
||||
});
|
||||
}
|
||||
|
||||
export function TeamHeroBanner() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAuthenticated(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="bg-white py-8 lg:py-12">
|
||||
<div className="container">
|
||||
<div className="flex flex-row items-center justify-start text-left lg:justify-between">
|
||||
<div className="flex flex-grow flex-col">
|
||||
<h1 className="mb-0.5 text-2xl font-bold sm:mb-2.5 sm:text-4xl lg:mb-4 lg:text-5xl">
|
||||
Roadmaps for Teams
|
||||
</h1>
|
||||
<p className="mb-4 text-base leading-normal text-gray-600 sm:mb-0 sm:leading-none lg:text-lg">
|
||||
Train, plan and track your team's skills and career growth.
|
||||
</p>
|
||||
|
||||
<ul className="mb-4 mt-0 hidden text-sm leading-7 sm:mb-4 sm:mt-4 sm:flex sm:flex-col lg:mb-6 lg:mt-6 lg:text-base lg:leading-8">
|
||||
{featureList.map((feature, index) => (
|
||||
<li key={feature}>
|
||||
<CheckCircle className="hidden h-6 w-6 text-green-500 sm:inline-block" />
|
||||
<span className="ml-0 sm:ml-2">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:items-center">
|
||||
<a
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
}}
|
||||
href={isAuthenticated ? '/team/new' : '/signup'}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-transparent bg-purple-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700 sm:w-auto sm:text-base"
|
||||
>
|
||||
Create your Team
|
||||
</a>
|
||||
{!isAuthenticated && (
|
||||
<>
|
||||
<span className="ml-1 hidden text-base sm:inline">
|
||||
or
|
||||
<a
|
||||
href="/login"
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
localStorage.setItem('authRedirect', '/team/new');
|
||||
}}
|
||||
className="text-purple-600 underline hover:text-purple-700"
|
||||
>
|
||||
Login to your account
|
||||
</a>
|
||||
</span>
|
||||
<a
|
||||
href="/login"
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
localStorage.setItem('authRedirect', '/team/new');
|
||||
}}
|
||||
className="flex w-full items-center justify-center rounded-lg border border-purple-600 px-5 py-2 text-base text-sm font-medium text-purple-600 hover:bg-blue-700 sm:hidden sm:text-base"
|
||||
>
|
||||
Login to your account
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
alt={'team roadmaps'}
|
||||
className="hidden h-64 md:block lg:h-80"
|
||||
src="/images/team-promo/hero-img.png"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
src/components/TeamMarketing/TeamPricing.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Check, CheckCircle, Copy, Sparkles } from 'lucide-react';
|
||||
import { useCopyText } from '../../hooks/use-copy-text.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import { fireTeamCreationClick } from './TeamHeroBanner.tsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function TeamPricing() {
|
||||
const { isCopied, copyText } = useCopyText();
|
||||
const teamEmail = 'teams@roadmap.sh';
|
||||
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
setIsAuthenticated(isLoggedIn());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="border-t py-4 sm:py-8 md:py-12">
|
||||
<div className="container">
|
||||
<h2 className="mb-1 text-xl font-bold sm:mb-1.5 sm:text-2xl md:mb-2 md:text-3xl">
|
||||
Beta Pricing
|
||||
</h2>
|
||||
<p className="mb-4 text-base text-gray-600 sm:mb-8 sm:text-lg">
|
||||
We are currently in public beta and are offering free access to all
|
||||
features.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-6 sm:flex-row sm:gap-4">
|
||||
<div className="relative flex flex-col items-center justify-center gap-2 overflow-hidden rounded-xl border border-purple-500">
|
||||
<div className="px-8 pb-2 pt-5 text-center sm:pt-4">
|
||||
<h3 className="mb-1 text-2xl font-bold">Free</h3>
|
||||
<p className="text-sm text-gray-500">No credit card required</p>
|
||||
<p className="flex items-start justify-center gap-1 py-6 text-3xl">
|
||||
<span className="text-base text-gray-600">$</span>
|
||||
<span className="text-5xl font-bold">0</span>
|
||||
</p>
|
||||
|
||||
<a
|
||||
onClick={() => {
|
||||
fireTeamCreationClick();
|
||||
if (isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('redirect', '/team/new');
|
||||
}}
|
||||
href={isAuthenticated ? '/team/new' : '/signup'}
|
||||
className="block rounded-md bg-purple-600 px-6 py-2 text-center text-sm font-medium leading-6 text-white shadow transition hover:bg-gray-700 hover:shadow-lg focus:outline-none"
|
||||
>
|
||||
{isAuthenticated ? 'Create your Team' : 'Sign up for free'}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1 border-t px-8 py-5 text-center sm:py-3">
|
||||
<p className="font-semibold text-black">All the features</p>
|
||||
<p className="text-gray-600">Roles and Permissions</p>
|
||||
<p className="text-gray-600">Custom Roadmaps</p>
|
||||
<p className="text-gray-600">Sharing Options</p>
|
||||
<p className="text-gray-600">Progress Tracking</p>
|
||||
<p className="text-gray-600">Team Insights</p>
|
||||
<p className="text-gray-600">Onboarding support</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-grow flex-col items-center justify-center rounded-md border border-gray-300 py-8">
|
||||
<img
|
||||
alt={'waving hand'}
|
||||
src={'/images/team-promo/contact.png'}
|
||||
className="mb-3 h-40"
|
||||
/>
|
||||
<p className="mb-2 font-medium text-gray-500">
|
||||
Questions? We are here to help!
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
<button
|
||||
onClick={() => {
|
||||
copyText(teamEmail);
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex items-center justify-between gap-3 overflow-hidden rounded-md border border-black bg-white px-4 py-2 text-black hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{teamEmail}
|
||||
<Copy
|
||||
className="relative top-[1px] ml-2 inline-block text-black transition-opacity"
|
||||
size={16}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 flex items-center justify-center bg-black text-white transition-all',
|
||||
{
|
||||
'top-full': !isCopied,
|
||||
'top-0': isCopied,
|
||||
}
|
||||
)}
|
||||
>
|
||||
Email copied!
|
||||
</span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/TeamMarketing/TeamTools.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
const toolsList = [
|
||||
{
|
||||
imageUrl: '/images/team-promo/growth-plans.png',
|
||||
title: 'Growth plans',
|
||||
description: 'Prepare shared or individual growth plans for members.',
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/team-promo/progress-tracking.png',
|
||||
title: 'Progress tracking',
|
||||
description: 'Track the and compare the progress of team members.',
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/team-promo/onboarding.png',
|
||||
title: 'Onboarding',
|
||||
description: 'Prepare onboarding plans for new team members.',
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/team-promo/team-insights.png',
|
||||
title: 'Team insights',
|
||||
description: 'Get insights about your team skills, progress and more.',
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/team-promo/skill-gap.png',
|
||||
title: 'Skill gap analysis',
|
||||
description: 'Understand the skills of your team and identify gaps.',
|
||||
},
|
||||
{
|
||||
imageUrl: '/images/team-promo/documentation.png',
|
||||
title: 'Documentation',
|
||||
description: 'Create and share visual team documentation.',
|
||||
},
|
||||
];
|
||||
|
||||
export function TeamTools() {
|
||||
return (
|
||||
<div className="py-4 sm:py-8 md:py-12 border-t">
|
||||
<div className="container">
|
||||
<h2 className="mb-1 sm:mb-1.5 md:mb-2 text-xl sm:text-2xl md:text-3xl font-bold">Track and guide your team’s knowledge</h2>
|
||||
<p className='text-sm md:text-base'>
|
||||
Individual and team level growth plans, progress tracking, skill gap analysis, team insights and more.
|
||||
</p>
|
||||
|
||||
<div className="mt-3 sm:mt-5 md:mt-8 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-2 sm:gap-4">
|
||||
{toolsList.map((tool) => {
|
||||
return (
|
||||
<div className="rounded-md sm:rounded-xl border p-2 sm:p-5 text-left sm:text-center md:text-left">
|
||||
<img
|
||||
alt={tool.title}
|
||||
src={tool.imageUrl}
|
||||
className="mb-5 h-48 hidden sm:block mx-auto md:mx-0"
|
||||
/>
|
||||
<h3 className="mb-0.5 sm:mb-2 text-lg sm:text-2xl font-bold">{tool.title}</h3>
|
||||
<p className='text-sm sm:text-base'>{tool.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,8 @@ export interface TeamMemberItem extends TeamMemberDocument {
|
||||
hasProgress: boolean;
|
||||
}
|
||||
|
||||
const MAX_MEMBER_COUNT = 100;
|
||||
|
||||
export function TeamMembersPage() {
|
||||
const { t: teamId } = getUrlParams();
|
||||
|
||||
@@ -307,7 +309,7 @@ export function TeamMembersPage() {
|
||||
{canManageCurrentTeam && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
disabled={teamMembers.length >= 25}
|
||||
disabled={teamMembers.length >= MAX_MEMBER_COUNT}
|
||||
onClick={() => setIsInvitingMember(true)}
|
||||
className="block w-full rounded-md border border-dashed border-gray-300 py-2 text-sm transition-colors hover:border-gray-600 hover:bg-gray-50 focus:outline-0"
|
||||
>
|
||||
@@ -316,7 +318,7 @@ export function TeamMembersPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teamMembers.length >= 25 && canManageCurrentTeam && (
|
||||
{teamMembers.length >= MAX_MEMBER_COUNT && canManageCurrentTeam && (
|
||||
<p className="mt-2 rounded-lg bg-red-100 p-2 text-red-700">
|
||||
You have reached the maximum number of members in a team. Please reach
|
||||
out to us if you need more.
|
||||
|
||||
@@ -311,6 +311,7 @@ export function TeamRoadmaps() {
|
||||
|
||||
const shareSettingsModal = selectedResource && (
|
||||
<ShareOptionsModal
|
||||
description={selectedResource.description!}
|
||||
visibility={selectedResource.visibility!}
|
||||
sharedTeamMemberIds={selectedResource.sharedTeamMemberIds!}
|
||||
sharedFriendIds={selectedResource.sharedFriendIds!}
|
||||
|
||||
@@ -9,5 +9,4 @@ For more resources, visit the following links:
|
||||
|
||||
- [Change Tracking in EF Core](https://learn.microsoft.com/en-us/ef/core/change-tracking/)
|
||||
- [Intro to Change Tracking](https://www.oreilly.com/library/view/programming-entity-framework/9781449331825/ch05.html)
|
||||
- [ChangeTracker in Entity Framework Core](https://www.entityframeworktutorial.net/efcore/changetracker-in-ef-core.aspxs)
|
||||
- [ChangeTracker in Entity Framework Core](https://www.entityframeworktutorial.net/efcore/changetracker-in-ef-core.aspx)
|
||||
- [ChangeTracker in Entity Framework Core](https://www.entityframeworktutorial.net/efcore/changetracker-in-ef-core.aspx)
|
||||
|
||||
@@ -85,7 +85,7 @@ int& numRef = num;
|
||||
User-defined data types are types that are defined by the programmer, such as structures, classes, and unions.
|
||||
|
||||
## Structures (struct)
|
||||
Structures are used to store different data tyes under a single variable and accessibility of member variables and methods are public.
|
||||
Structures are used to store different data types under a single variable and accessibility of member variables and methods are public.
|
||||
|
||||
Example:
|
||||
```cpp
|
||||
|
||||
@@ -21,4 +21,4 @@ Services such as [CloudFlare](https://www.cloudflare.com/dns/) and [Route53](htt
|
||||
To learn more, visit the following links:
|
||||
|
||||
- [Getting started with Domain Name System](https://github.com/donnemartin/system-design-primer#domain-name-system)
|
||||
[What is DNS?](https://www.cloudflare.com/learning/dns/what-is-dns/)
|
||||
- [What is DNS?](https://www.cloudflare.com/learning/dns/what-is-dns/)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# instanceOf operator
|
||||
# instanceof operator
|
||||
|
||||
The `instanceof` operator is a way to narrow down the type of a variable. It is used to check if an object is an instance of a class.
|
||||
|
||||
|
||||
1
src/icons/letter.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--ic" width="35" height="35" viewBox="0 0 24 24"><path fill="currentColor" d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5l-8-5V6l8 5l8-5v2z"></path></svg>
|
||||
|
After Width: | Height: | Size: 338 B |
14
src/pages/teams.astro
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { TeamHeroBanner } from '../components/TeamMarketing/TeamHeroBanner';
|
||||
import { TeamTools } from '../components/TeamMarketing/TeamTools';
|
||||
import { TeamDemo } from '../components/TeamMarketing/TeamDemo';
|
||||
import { TeamPricing } from '../components/TeamMarketing/TeamPricing';
|
||||
---
|
||||
|
||||
<BaseLayout title='Roadmaps for teams' permalink={'/teams'}>
|
||||
<TeamHeroBanner client:load />
|
||||
<TeamTools />
|
||||
<TeamDemo client:load />
|
||||
<TeamPricing client:load />
|
||||
</BaseLayout>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { atom, computed } from 'nanostores';
|
||||
import { type RoadmapDocument } from '../components/CustomRoadmap/CreateRoadmap/CreateRoadmapModal';
|
||||
import type { GetRoadmapResponse } from '../components/CustomRoadmap/CustomRoadmap';
|
||||
|
||||
export const currentRoadmap = atom<RoadmapDocument | undefined>(undefined);
|
||||
export const currentRoadmap = atom<GetRoadmapResponse | undefined>(undefined);
|
||||
export const isCurrentRoadmapPersonal = computed(
|
||||
currentRoadmap,
|
||||
(roadmap) => !roadmap?.teamId
|
||||
|
||||