feat: share solution modal

feat/list-projects
Arik Chakma 3 months ago
parent 7da86f173b
commit 018a8d6f0f
  1. 3
      src/components/Projects/ListProjectSolutions.tsx
  2. 198
      src/components/Projects/ProjectSolutionModal.tsx
  3. 53
      src/components/Projects/StatusStepper/ProjectStepper.tsx
  4. 7
      src/components/Projects/SubmitSuccessModal.tsx
  5. 16
      src/pages/projects/[projectId]/solutions.astro

@ -15,6 +15,7 @@ import { VoteButton } from './VoteButton.tsx';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx'; import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import { SelectLanguages } from './SelectLanguages.tsx'; import { SelectLanguages } from './SelectLanguages.tsx';
import type { ProjectFrontmatter } from '../../lib/project.ts'; import type { ProjectFrontmatter } from '../../lib/project.ts';
import { ProjectSolutionModal } from './ProjectSolutionModal.tsx';
export interface ProjectStatusDocument { export interface ProjectStatusDocument {
_id?: string; _id?: string;
@ -68,7 +69,7 @@ type ListProjectSolutionsProps = {
projectId: string; projectId: string;
}; };
const submittedAlternatives = [ export const submittedAlternatives = [
'submitted their solution', 'submitted their solution',
'got it done', 'got it done',
'submitted their take', 'submitted their take',

@ -0,0 +1,198 @@
import { useEffect, useState } from 'react';
import { deleteUrlParam, getUrlParams } from '../../lib/browser';
import { ModalLoader } from '../UserProgress/ModalLoader';
import { Modal } from '../Modal';
import { httpGet, httpPost } from '../../lib/http';
import {
submittedAlternatives,
type AllowedVoteType,
} from './ListProjectSolutions';
import { getRelativeTimeString } from '../../lib/date';
import { ArrowUpRight, ThumbsDown, ThumbsUp } from 'lucide-react';
import { VoteButton } from './VoteButton';
import { GitHubIcon } from '../ReactIcons/GitHubIcon';
import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup';
import { pageProgressMessage } from '../../stores/page';
import { useToast } from '../../hooks/use-toast';
type UserProjectSolutionResponse = {
id?: string;
startedAt?: Date;
submittedAt?: Date;
repositoryUrl?: string;
upvotes?: number;
downvotes?: number;
voteType?: AllowedVoteType | 'none';
user: {
id: string;
name: string;
avatar: string;
};
};
type ProjectSolutionModalProps = {
projectId: string;
projectTitle: string;
projectDescription: string;
};
export function ProjectSolutionModal(props: ProjectSolutionModalProps) {
const { projectId, projectTitle, projectDescription } = props;
const { u: userId } = getUrlParams();
if (!userId) {
return null;
}
const toast = useToast();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const [solution, setSolution] = useState<UserProjectSolutionResponse>();
const loadUserProjectSolution = async () => {
setIsLoading(true);
setError('');
const { response, error } = await httpGet<UserProjectSolutionResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-project-solution/${projectId}/${userId}`,
);
if (error || !response) {
setError(error?.message || 'Something went wrong');
setIsLoading(false);
return;
}
setSolution(response);
setIsLoading(false);
};
const handleSubmitVote = async (
solutionId: string,
voteType: AllowedVoteType,
) => {
if (!isLoggedIn()) {
showLoginPopup();
return;
}
pageProgressMessage.set('Submitting vote');
const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-vote-project/${solutionId}`,
{
voteType,
},
);
if (error || !response) {
toast.error(error?.message || 'Failed to submit vote');
pageProgressMessage.set('');
return;
}
pageProgressMessage.set('');
setSolution((prev) => {
if (!prev) {
return prev;
}
return {
...prev,
upvotes: response?.upvotes || 0,
downvotes: response?.downvotes || 0,
voteType,
};
});
};
useEffect(() => {
loadUserProjectSolution().finally();
}, []);
if (isLoading || error) {
return (
<ModalLoader
text="Loading project solution..."
isLoading={isLoading}
error={error}
/>
);
}
const avatar = solution?.user.avatar;
return (
<Modal
onClose={() => {
deleteUrlParam('u');
window.location.reload();
}}
>
<div className="relative p-4">
<h1 className="text-xl font-semibold">{projectTitle}</h1>
<p className="mt-1 max-w-xs text-sm text-gray-500">
{projectDescription}
</p>
<hr className="-mx-4 my-4 border-gray-300" />
<div className="flex items-center gap-1.5">
<img
src={
avatar
? `${import.meta.env.PUBLIC_AVATAR_BASE_URL}/${avatar}`
: '/images/default-avatar.png'
}
alt={solution?.user?.name}
className="mr-0.5 h-7 w-7 rounded-full"
/>
<span className="font-medium text-black">{solution?.user.name}</span>
<span className="hidden sm:inline">
{submittedAlternatives[
Math.floor(Math.random() * submittedAlternatives.length)
] || 'submitted their solution'}
</span>{' '}
<span className="flex-grow text-right text-gray-400 sm:flex-grow-0 sm:text-left sm:font-medium sm:text-black">
{getRelativeTimeString(solution?.submittedAt!)}
</span>
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<a
className="flex items-center gap-1 rounded-full border px-2 py-1 text-xs text-black transition-colors hover:border-black hover:bg-black hover:text-white"
href={solution?.repositoryUrl}
target="_blank"
>
<GitHubIcon className="h-4 w-4 text-current" />
View Solution
</a>
<div className="flex shrink-0 overflow-hidden rounded-full border">
<VoteButton
icon={ThumbsUp}
isActive={solution?.voteType === 'upvote'}
count={solution?.upvotes || 0}
onClick={() => {
handleSubmitVote(solution?.id!, 'upvote');
}}
/>
<VoteButton
icon={ThumbsDown}
isActive={solution?.voteType === 'downvote'}
count={solution?.downvotes || 0}
hideCount={true}
onClick={() => {
handleSubmitVote(solution?.id!, 'downvote');
}}
/>
</div>
</div>
</div>
</Modal>
);
}

@ -1,4 +1,4 @@
import { Flag, Play, Send } from 'lucide-react'; import { Flag, Play, Send, Share } from 'lucide-react';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { cn } from '../../../lib/classname.ts'; import { cn } from '../../../lib/classname.ts';
import { useStickyStuck } from '../../../hooks/use-sticky-stuck.tsx'; import { useStickyStuck } from '../../../hooks/use-sticky-stuck.tsx';
@ -8,9 +8,11 @@ import { MilestoneStep } from './MilestoneStep.tsx';
import { httpGet } from '../../../lib/http.ts'; import { httpGet } from '../../../lib/http.ts';
import { StartProjectModal } from '../StartProjectModal.tsx'; import { StartProjectModal } from '../StartProjectModal.tsx';
import { getRelativeTimeString } from '../../../lib/date.ts'; import { getRelativeTimeString } from '../../../lib/date.ts';
import { isLoggedIn } from '../../../lib/jwt.ts'; import { getUser, isLoggedIn } from '../../../lib/jwt.ts';
import { showLoginPopup } from '../../../lib/popup.ts'; import { showLoginPopup } from '../../../lib/popup.ts';
import { SubmitProjectModal } from '../SubmitProjectModal.tsx'; import { SubmitProjectModal } from '../SubmitProjectModal.tsx';
import { useCopyText } from '../../../hooks/use-copy-text.ts';
import { CheckIcon } from '../../ReactIcons/CheckIcon.tsx';
type ProjectStatusResponse = { type ProjectStatusResponse = {
id?: string; id?: string;
@ -32,9 +34,11 @@ export function ProjectStepper(props: ProjectStepperProps) {
const stickyElRef = useRef<HTMLDivElement>(null); const stickyElRef = useRef<HTMLDivElement>(null);
const isSticky = useStickyStuck(stickyElRef, 8); const isSticky = useStickyStuck(stickyElRef, 8);
const currentUser = getUser();
const [isStartingProject, setIsStartingProject] = useState(false); const [isStartingProject, setIsStartingProject] = useState(false);
const [isSubmittingProject, setIsSubmittingProject] = useState(false); const [isSubmittingProject, setIsSubmittingProject] = useState(false);
const { copyText, isCopied } = useCopyText();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeStep, setActiveStep] = useState<number>(0); const [activeStep, setActiveStep] = useState<number>(0);
@ -78,13 +82,16 @@ export function ProjectStepper(props: ProjectStepperProps) {
loadProjectStatus().finally(() => {}); loadProjectStatus().finally(() => {});
}, []); }, []);
const projectSolutionUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}/solutions?u=${currentUser?.id}`;
return ( return (
<div <div
ref={stickyElRef} ref={stickyElRef}
className={cn( className={cn(
'relative sm:sticky top-0 my-5 -mx-4 sm:mx-0 overflow-hidden rounded-none border-x-0 sm:border-x sm:rounded-lg border bg-white transition-all', 'relative top-0 -mx-4 my-5 overflow-hidden rounded-none border border-x-0 bg-white transition-all sm:sticky sm:mx-0 sm:rounded-lg sm:border-x',
{ {
'sm:-mx-5 sm:rounded-none sm:border-x-0 sm:border-t-0 sm:bg-gray-50': isSticky, 'sm:-mx-5 sm:rounded-none sm:border-x-0 sm:border-t-0 sm:bg-gray-50':
isSticky,
}, },
)} )}
> >
@ -131,7 +138,7 @@ export function ProjectStepper(props: ProjectStepperProps) {
)} )}
<div <div
className={cn( className={cn(
'px-4 py-2 text-sm text-gray-500 transition-colors bg-gray-100', 'flex items-center bg-gray-100 px-4 py-2 text-sm text-gray-500 transition-colors',
{ {
'bg-purple-600 text-white': isSticky, 'bg-purple-600 text-white': isSticky,
}, },
@ -144,7 +151,7 @@ export function ProjectStepper(props: ProjectStepperProps) {
)} )}
{activeStep === 1 && ( {activeStep === 1 && (
<> <>
Started working{' '} Started working&nbsp;
<span <span
className={cn('font-medium text-gray-800', { className={cn('font-medium text-gray-800', {
'text-purple-200': isSticky, 'text-purple-200': isSticky,
@ -152,7 +159,7 @@ export function ProjectStepper(props: ProjectStepperProps) {
> >
{getRelativeTimeString(projectStatus.startedAt!)} {getRelativeTimeString(projectStatus.startedAt!)}
</span> </span>
. Follow{' '} . Follow&nbsp;
<button <button
className={cn('underline underline-offset-2 hover:text-black', { className={cn('underline underline-offset-2 hover:text-black', {
'text-purple-100 hover:text-white': isSticky, 'text-purple-100 hover:text-white': isSticky,
@ -162,13 +169,13 @@ export function ProjectStepper(props: ProjectStepperProps) {
}} }}
> >
these tips these tips
</button>{' '} </button>
to get most out of it. &nbsp; to get most out of it.
</> </>
)} )}
{activeStep >= 2 && ( {activeStep >= 2 && (
<> <>
Congrats on submitting your solution.{' '} Congrats on submitting your solution.&nbsp;
<button <button
className={cn('underline underline-offset-2 hover:text-black', { className={cn('underline underline-offset-2 hover:text-black', {
'text-purple-100 hover:text-white': isSticky, 'text-purple-100 hover:text-white': isSticky,
@ -181,9 +188,33 @@ export function ProjectStepper(props: ProjectStepperProps) {
</button> </button>
</> </>
)} )}
{activeStep >= 1 && (
<button
className={cn(
'ml-auto flex items-center gap-1 text-sm',
isCopied ? 'text-green-500' : '',
)}
onClick={() => {
copyText(projectSolutionUrl);
}}
>
{isCopied ? (
<>
<CheckIcon additionalClasses="h-3 w-3" />
Copied
</>
) : (
<>
<Share className="h-3 w-3 stroke-[2.5px]" />
Share Solution
</>
)}
</button>
)}
</div> </div>
<div className="flex flex-col sm:flex-row min-h-[60px] items-start sm:items-center justify-between gap-2 sm:gap-3 px-4 py-4 sm:py-0"> <div className="flex min-h-[60px] flex-col items-start justify-between gap-2 px-4 py-4 sm:flex-row sm:items-center sm:gap-3 sm:py-0">
<StepperAction <StepperAction
isActive={activeStep === 0} isActive={activeStep === 0}
isCompleted={activeStep > 0} isCompleted={activeStep > 0}

@ -23,7 +23,7 @@ export function SubmitSuccessModal(props: SubmitSuccessModalProps) {
const user = getUser(); const user = getUser();
const description = 'Check out my solution to this project on Roadmap.sh'; const description = 'Check out my solution to this project on Roadmap.sh';
const projectSolutionUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}?u=${user?.id}`; const projectSolutionUrl = `${import.meta.env.DEV ? 'http://localhost:3000' : 'https://roadmap.sh'}/projects/${projectId}/solutions?u=${user?.id}`;
const { isCopied, copyText } = useCopyText(); const { isCopied, copyText } = useCopyText();
@ -49,10 +49,7 @@ export function SubmitSuccessModal(props: SubmitSuccessModalProps) {
<Modal onClose={onClose} bodyClassName="h-auto p-4"> <Modal onClose={onClose} bodyClassName="h-auto p-4">
<div className="flex flex-col items-center justify-center pb-5 pt-12"> <div className="flex flex-col items-center justify-center pb-5 pt-12">
<ReactCheckIcon additionalClasses="h-12 text-green-500 w-12" /> <ReactCheckIcon additionalClasses="h-12 text-green-500 w-12" />
{/* <p className="text-lg font-medium">{successMessage}</p> */} <p className="mt-4 text-lg font-medium">{successMessage}</p>
<p className="mt-4 text-lg font-medium">
Solution submitted successfully!
</p>
<p className="mt-0.5 text-center text-sm text-gray-500"> <p className="mt-0.5 text-center text-sm text-gray-500">
You can use the link to share your solution with others. You can use the link to share your solution with others.
</p> </p>

@ -1,14 +1,13 @@
--- ---
import BaseLayout from '../../../layouts/BaseLayout.astro'; import BaseLayout from '../../../layouts/BaseLayout.astro';
import { Badge } from '../../../components/Badge';
import { import {
getAllProjects, getAllProjects,
getProjectById, getProjectById,
type ProjectFrontmatter, type ProjectFrontmatter,
} from '../../../lib/project'; } from '../../../lib/project';
import AstroIcon from '../../../components/AstroIcon.astro';
import { ProjectTabs } from '../../../components/Projects/ProjectTabs'; import { ProjectTabs } from '../../../components/Projects/ProjectTabs';
import { ListProjectSolutions } from '../../../components/Projects/ListProjectSolutions'; import { ListProjectSolutions } from '../../../components/Projects/ListProjectSolutions';
import { ProjectSolutionModal } from '../../../components/Projects/ProjectSolutionModal';
export async function getStaticPaths() { export async function getStaticPaths() {
const projects = await getAllProjects(); const projects = await getAllProjects();
@ -49,13 +48,24 @@ const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste
> >
<div class='bg-gray-50'> <div class='bg-gray-50'>
<div class='container'> <div class='container'>
<ProjectTabs parentRoadmapId={parentRoadmapId} projectId={projectId} activeTab='solutions' /> <ProjectTabs
parentRoadmapId={parentRoadmapId}
projectId={projectId}
activeTab='solutions'
/>
<ListProjectSolutions <ListProjectSolutions
project={projectData} project={projectData}
projectId={projectId} projectId={projectId}
client:load client:load
/> />
<ProjectSolutionModal
projectId={projectId}
projectTitle={projectData.title}
projectDescription={projectData.description}
client:only='react'
/>
</div> </div>
</div> </div>
</BaseLayout> </BaseLayout>

Loading…
Cancel
Save