mirror of
https://github.com/kamranahmedse/developer-roadmap.git
synced 2025-08-31 21:11:44 +02:00
Add contribution functionality
This commit is contained in:
226
src/components/TopicDetail/ContributionForm.tsx
Normal file
226
src/components/TopicDetail/ContributionForm.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { httpPost } from '../../lib/http';
|
||||||
|
|
||||||
|
type ContributionInputProps = {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
link: string;
|
||||||
|
isLast: boolean;
|
||||||
|
totalCount: number;
|
||||||
|
onAdd: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onChange: (link: { id: number; title: string; link: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ContributionInput(props: ContributionInputProps) {
|
||||||
|
const {
|
||||||
|
isLast,
|
||||||
|
totalCount,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
onChange,
|
||||||
|
id,
|
||||||
|
title: defaultTitle,
|
||||||
|
link: defaultLink,
|
||||||
|
} = props;
|
||||||
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [focused, setFocused] = useState('');
|
||||||
|
const [title, setTitle] = useState(defaultTitle);
|
||||||
|
const [link, setLink] = useState(defaultLink);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!titleRef?.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
titleRef.current.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onChange({ id, title, link });
|
||||||
|
}, [title, link]);
|
||||||
|
|
||||||
|
const canAddMore = isLast && totalCount < 5;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mb-3 rounded-md border p-3">
|
||||||
|
<p
|
||||||
|
className={`mb-1 text-xs uppercase ${
|
||||||
|
focused === 'title' ? 'text-black' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Resource Title
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={titleRef}
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="block w-full rounded-md border p-2 text-sm focus:border-gray-400 focus:outline-none"
|
||||||
|
placeholder="e.g. Introduction to RESTful APIs"
|
||||||
|
onFocus={() => setFocused('title')}
|
||||||
|
onBlur={() => setFocused('')}
|
||||||
|
onChange={(e) => setTitle((e.target as any).value)}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
className={`mb-1 mt-3 text-xs uppercase ${
|
||||||
|
focused === 'link' ? 'text-black' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Resource Link
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
required
|
||||||
|
className="block w-full rounded-md border p-2 text-sm focus:border-gray-400 focus:outline-none"
|
||||||
|
placeholder="e.g. https://roadmap.sh/guides/some-url"
|
||||||
|
onFocus={() => setFocused('link')}
|
||||||
|
onBlur={() => setFocused('')}
|
||||||
|
onChange={(e) => setLink((e.target as any).value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mb-0 mt-3 flex gap-3">
|
||||||
|
{totalCount !== 1 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
|
className="rounded-md text-sm font-semibold text-red-500 underline underline-offset-2 hover:text-red-800"
|
||||||
|
>
|
||||||
|
- Remove Link
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canAddMore && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onAdd();
|
||||||
|
}}
|
||||||
|
className="rounded-md text-sm font-semibold text-gray-600 underline underline-offset-2 hover:text-black"
|
||||||
|
>
|
||||||
|
+ Add another Link
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContributionFormProps = {
|
||||||
|
resourceType: string;
|
||||||
|
resourceId: string;
|
||||||
|
topicId: string;
|
||||||
|
onClose: (message?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ContributionForm(props: ContributionFormProps) {
|
||||||
|
const { onClose, resourceType, resourceId, topicId } = props;
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [links, setLinks] = useState<
|
||||||
|
{ id: number; title: string; link: string }[]
|
||||||
|
>([
|
||||||
|
{
|
||||||
|
id: new Date().getTime(),
|
||||||
|
title: '',
|
||||||
|
link: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function onSubmit(e: any) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const { response, error } = await httpPost(
|
||||||
|
`${import.meta.env.PUBLIC_API_URL}/v1-contribute-link`,
|
||||||
|
{
|
||||||
|
resourceType,
|
||||||
|
resourceId,
|
||||||
|
topicId,
|
||||||
|
links,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (!response || error) {
|
||||||
|
alert(error?.message || 'Something went wrong. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose('Thanks for your contribution! We will review it shortly.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 mt-2 rounded-md border bg-gray-100 p-3">
|
||||||
|
<h1 className="mb-2 text-2xl font-bold">Guidelines</h1>
|
||||||
|
<ul class="flex flex-col gap-1 text-sm text-gray-700">
|
||||||
|
<li>Content should only be in English</li>
|
||||||
|
<li>Do not add things you have not evaluated personally.</li>
|
||||||
|
<li>It should strictly be relevant to the topic.</li>
|
||||||
|
<li>It should not be paid or behind a signup.</li>
|
||||||
|
<li>
|
||||||
|
Quality over quantity. Smaller set of quality links is preferred.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
{links.map((link, counter) => (
|
||||||
|
<ContributionInput
|
||||||
|
key={link.id}
|
||||||
|
id={link.id}
|
||||||
|
title={link.title}
|
||||||
|
link={link.link}
|
||||||
|
isLast={counter === links.length - 1}
|
||||||
|
totalCount={links.length}
|
||||||
|
onChange={(newLink) => {
|
||||||
|
setLinks(
|
||||||
|
links.map((l) => {
|
||||||
|
if (l.id === link.id) {
|
||||||
|
return newLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
return l;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onRemove={() => {
|
||||||
|
setLinks(links.filter((l) => l.id !== link.id));
|
||||||
|
}}
|
||||||
|
onAdd={() => {
|
||||||
|
setLinks([
|
||||||
|
...links,
|
||||||
|
{
|
||||||
|
id: new Date().getTime(),
|
||||||
|
title: '',
|
||||||
|
link: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
disabled={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
className="block w-full rounded-md bg-gray-800 p-2 text-sm text-white hover:bg-black disabled:cursor-not-allowed disabled:bg-gray-400"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Please wait ...' : 'Submit'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="block w-full rounded-md border border-red-500 p-2 text-sm text-red-600 hover:bg-red-600 hover:text-white"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@@ -16,10 +16,13 @@ import {
|
|||||||
} from '../../lib/resource-progress';
|
} from '../../lib/resource-progress';
|
||||||
import { pageProgressMessage, sponsorHidden } from '../../stores/page';
|
import { pageProgressMessage, sponsorHidden } from '../../stores/page';
|
||||||
import { TopicProgressButton } from './TopicProgressButton';
|
import { TopicProgressButton } from './TopicProgressButton';
|
||||||
|
import { ContributionForm } from './ContributionForm';
|
||||||
|
|
||||||
export function TopicDetail() {
|
export function TopicDetail() {
|
||||||
|
const [contributionAlertMessage, setContributionAlertMessage] = useState('');
|
||||||
const [isActive, setIsActive] = useState(false);
|
const [isActive, setIsActive] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isContributing, setIsContributing] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [topicHtml, setTopicHtml] = useState('');
|
const [topicHtml, setTopicHtml] = useState('');
|
||||||
|
|
||||||
@@ -45,14 +48,15 @@ export function TopicDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// Close the topic detail when user clicks outside the topic detail
|
// Close the topic detail when user clicks outside the topic detail
|
||||||
useOutsideClick(topicRef, () => {
|
useOutsideClick(topicRef, () => {
|
||||||
setIsActive(false);
|
setIsActive(false);
|
||||||
|
setIsContributing(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
useKeydown('Escape', () => {
|
useKeydown('Escape', () => {
|
||||||
setIsActive(false);
|
setIsActive(false);
|
||||||
|
setIsContributing(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Toggle topic is available even if the component UI is not active
|
// Toggle topic is available even if the component UI is not active
|
||||||
@@ -99,6 +103,7 @@ export function TopicDetail() {
|
|||||||
setIsActive(true);
|
setIsActive(true);
|
||||||
sponsorHidden.set(true);
|
sponsorHidden.set(true);
|
||||||
|
|
||||||
|
setContributionAlertMessage('');
|
||||||
setTopicId(topicId);
|
setTopicId(topicId);
|
||||||
setResourceType(resourceType);
|
setResourceType(resourceType);
|
||||||
setResourceId(resourceId);
|
setResourceId(resourceId);
|
||||||
@@ -142,10 +147,6 @@ export function TopicDetail() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contributionDir =
|
|
||||||
resourceType === 'roadmap' ? 'roadmaps' : 'best-practices';
|
|
||||||
const contributionUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/master/src/data/${contributionDir}/${resourceId}/content`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
@@ -162,7 +163,22 @@ export function TopicDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && !error && (
|
{!isLoading && isContributing && (
|
||||||
|
<ContributionForm
|
||||||
|
resourceType={resourceType}
|
||||||
|
resourceId={resourceId}
|
||||||
|
topicId={topicId}
|
||||||
|
onClose={(message?: string) => {
|
||||||
|
if (message) {
|
||||||
|
setContributionAlertMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsContributing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isContributing && !isLoading && !error && (
|
||||||
<>
|
<>
|
||||||
{/* Actions for the topic */}
|
{/* Actions for the topic */}
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
@@ -173,6 +189,7 @@ export function TopicDetail() {
|
|||||||
onShowLoginPopup={showLoginPopup}
|
onShowLoginPopup={showLoginPopup}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setIsActive(false);
|
setIsActive(false);
|
||||||
|
setIsContributing(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -180,7 +197,10 @@ export function TopicDetail() {
|
|||||||
type="button"
|
type="button"
|
||||||
id="close-topic"
|
id="close-topic"
|
||||||
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
|
className="absolute right-2.5 top-2.5 inline-flex items-center rounded-lg bg-transparent p-1.5 text-sm text-gray-400 hover:bg-gray-200 hover:text-gray-900"
|
||||||
onClick={() => setIsActive(false)}
|
onClick={() => {
|
||||||
|
setIsActive(false);
|
||||||
|
setIsContributing(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img alt="Close" class="h-5 w-5" src={CloseIcon} />
|
<img alt="Close" class="h-5 w-5" src={CloseIcon} />
|
||||||
</button>
|
</button>
|
||||||
@@ -193,20 +213,29 @@ export function TopicDetail() {
|
|||||||
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
dangerouslySetInnerHTML={{ __html: topicHtml }}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<p
|
{/* Contribution */}
|
||||||
id="contrib-meta"
|
<div className="mt-8 flex-1 border-t">
|
||||||
class="mt-10 border-t pt-3 text-sm leading-relaxed text-gray-400"
|
<p class="mb-2 mt-2 text-sm leading-relaxed text-gray-400">
|
||||||
>
|
|
||||||
Contribute links to learning resources about this topic{' '}
|
Contribute links to learning resources about this topic{' '}
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
class="text-blue-700 underline"
|
|
||||||
href={contributionUrl}
|
|
||||||
>
|
|
||||||
on GitHub repository.
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (isGuest) {
|
||||||
|
setIsActive(false);
|
||||||
|
showLoginPopup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsContributing(true);
|
||||||
|
}}
|
||||||
|
disabled={!!contributionAlertMessage}
|
||||||
|
className="block w-full rounded-md bg-gray-800 p-2 text-sm text-white transition-colors hover:bg-black hover:text-white disabled:bg-green-200 disabled:text-black"
|
||||||
|
>
|
||||||
|
{contributionAlertMessage
|
||||||
|
? contributionAlertMessage
|
||||||
|
: 'Submit a Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
Reference in New Issue
Block a user