feat: add project languages (#6765)

* feat: add project languages

* fix: update select languages

* fix: select language

* Update UI for project languages

---------

Co-authored-by: Kamran Ahmed <kamranahmed.se@gmail.com>
pull/6812/head
Arik Chakma 5 months ago committed by GitHub
parent 4b7022948a
commit 2b6c326819
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 2
      src/components/Projects/EmptySolutions.tsx
  2. 11
      src/components/Projects/LeavingRoadmapWarningModal.tsx
  3. 252
      src/components/Projects/ListProjectSolutions.tsx
  4. 88
      src/components/Projects/SelectLanguages.tsx
  5. 11
      src/components/Projects/SubmitProjectModal.tsx
  6. 17
      src/pages/projects/[projectId]/solutions.astro

@ -8,7 +8,7 @@ export function EmptySolutions(props: EmptySolutionsProps) {
const { projectId } = props; const { projectId } = props;
return ( return (
<div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl px-5 py-3 sm:px-0 sm:py-20"> <div className="flex min-h-[250px] flex-col items-center justify-center rounded-xl px-5 py-3 sm:px-0 sm:py-20 bg-white border mb-5">
<Blocks className="mb-4 opacity-10 h-14 w-14" /> <Blocks className="mb-4 opacity-10 h-14 w-14" />
<h2 className="mb-1 text-lg font-semibold sm:text-xl"> <h2 className="mb-1 text-lg font-semibold sm:text-xl">
No solutions submitted yet No solutions submitted yet

@ -4,13 +4,13 @@ import { SubmissionRequirement } from './SubmissionRequirement.tsx';
type LeavingRoadmapWarningModalProps = { type LeavingRoadmapWarningModalProps = {
onClose: () => void; onClose: () => void;
onContinue: () => void; repositoryUrl: string;
}; };
export function LeavingRoadmapWarningModal( export function LeavingRoadmapWarningModal(
props: LeavingRoadmapWarningModalProps, props: LeavingRoadmapWarningModalProps,
) { ) {
const { onClose, onContinue } = props; const { onClose, repositoryUrl } = props;
return ( return (
<Modal onClose={onClose} bodyClassName="h-auto p-4"> <Modal onClose={onClose} bodyClassName="h-auto p-4">
@ -45,13 +45,14 @@ export function LeavingRoadmapWarningModal(
</p> </p>
</div> </div>
<button <a
className="inline-flex w-full items-center gap-2 rounded-lg bg-black px-3 py-2.5 text-sm text-white" className="inline-flex w-full items-center gap-2 rounded-lg bg-black px-3 py-2.5 text-sm text-white"
onClick={onContinue} href={repositoryUrl}
target="_blank"
> >
<ArrowUpRight className="h-5 w-5" /> <ArrowUpRight className="h-5 w-5" />
Continue to Solution Continue to Solution
</button> </a>
<button <button
className="absolute right-2.5 top-2.5 text-gray-600 hover:text-black" className="absolute right-2.5 top-2.5 text-gray-600 hover:text-black"

@ -13,7 +13,8 @@ import { isLoggedIn } from '../../lib/jwt';
import { showLoginPopup } from '../../lib/popup'; import { showLoginPopup } from '../../lib/popup';
import { VoteButton } from './VoteButton.tsx'; import { VoteButton } from './VoteButton.tsx';
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx'; import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
import { cn } from '../../lib/classname.ts'; import { SelectLanguages } from './SelectLanguages.tsx';
import type { ProjectFrontmatter } from '../../lib/project.ts';
export interface ProjectStatusDocument { export interface ProjectStatusDocument {
_id?: string; _id?: string;
@ -24,6 +25,7 @@ export interface ProjectStatusDocument {
startedAt?: Date; startedAt?: Date;
submittedAt?: Date; submittedAt?: Date;
repositoryUrl?: string; repositoryUrl?: string;
languages?: string[];
upvotes: number; upvotes: number;
downvotes: number; downvotes: number;
@ -53,15 +55,16 @@ type ListProjectSolutionsResponse = {
type QueryParams = { type QueryParams = {
p?: string; p?: string;
l?: string;
}; };
type PageState = { type PageState = {
currentPage: number; currentPage: number;
language: string;
}; };
const VISITED_SOLUTIONS_KEY = 'visited-project-solutions';
type ListProjectSolutionsProps = { type ListProjectSolutionsProps = {
project: ProjectFrontmatter;
projectId: string; projectId: string;
}; };
@ -90,27 +93,26 @@ const submittedAlternatives = [
]; ];
export function ListProjectSolutions(props: ListProjectSolutionsProps) { export function ListProjectSolutions(props: ListProjectSolutionsProps) {
const { projectId } = props; const { projectId, project: projectData } = props;
const toast = useToast(); const toast = useToast();
const [pageState, setPageState] = useState<PageState>({ const [pageState, setPageState] = useState<PageState>({
currentPage: 0, currentPage: 0,
language: '',
}); });
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [solutions, setSolutions] = useState<ListProjectSolutionsResponse>(); const [solutions, setSolutions] = useState<ListProjectSolutionsResponse>();
const [alreadyVisitedSolutions, setAlreadyVisitedSolutions] = useState<
Record<string, boolean>
>({});
const [showLeavingRoadmapModal, setShowLeavingRoadmapModal] = useState< const [showLeavingRoadmapModal, setShowLeavingRoadmapModal] = useState<
ListProjectSolutionsResponse['data'][number] | null ListProjectSolutionsResponse['data'][number] | null
>(null); >(null);
const loadSolutions = async (page = 1) => { const loadSolutions = async (page = 1, language: string = '') => {
const { response, error } = await httpGet<ListProjectSolutionsResponse>( const { response, error } = await httpGet<ListProjectSolutionsResponse>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-project-solutions/${projectId}`, `${import.meta.env.PUBLIC_API_URL}/v1-list-project-solutions/${projectId}`,
{ {
currPage: page, currPage: page,
...(language ? { languages: language } : {}),
}, },
); );
@ -132,7 +134,7 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) {
return; return;
} }
pageProgressMessage.set('Submitting vote...'); pageProgressMessage.set('Submitting vote');
const { response, error } = await httpPost( const { response, error } = await httpPost(
`${import.meta.env.PUBLIC_API_URL}/v1-vote-project/${solutionId}`, `${import.meta.env.PUBLIC_API_URL}/v1-vote-project/${solutionId}`,
{ {
@ -172,13 +174,9 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) {
useEffect(() => { useEffect(() => {
const queryParams = getUrlParams() as QueryParams; const queryParams = getUrlParams() as QueryParams;
const alreadyVisitedSolutions = JSON.parse(
localStorage.getItem(VISITED_SOLUTIONS_KEY) || '{}',
);
setAlreadyVisitedSolutions(alreadyVisitedSolutions);
setPageState({ setPageState({
currentPage: +(queryParams.p || '1'), currentPage: +(queryParams.p || '1'),
language: queryParams.l || '',
}); });
}, []); }, []);
@ -188,23 +186,21 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) {
return; return;
} }
if (pageState.currentPage !== 1) { if (pageState.currentPage !== 1 || pageState.language !== '') {
setUrlParams({ setUrlParams({
p: String(pageState.currentPage), p: String(pageState.currentPage),
l: pageState.language,
}); });
} else { } else {
deleteUrlParam('p'); deleteUrlParam('p');
deleteUrlParam('l');
} }
loadSolutions(pageState.currentPage).finally(() => { loadSolutions(pageState.currentPage, pageState.language).finally(() => {
setIsLoading(false); setIsLoading(false);
}); });
}, [pageState]); }, [pageState]);
if (isLoading) {
return <LoadingSolutions />;
}
const isEmpty = solutions?.data.length === 0; const isEmpty = solutions?.data.length === 0;
if (isEmpty) { if (isEmpty) {
return <EmptySolutions projectId={projectId} />; return <EmptySolutions projectId={projectId} />;
@ -213,116 +209,128 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) {
const leavingRoadmapModal = showLeavingRoadmapModal ? ( const leavingRoadmapModal = showLeavingRoadmapModal ? (
<LeavingRoadmapWarningModal <LeavingRoadmapWarningModal
onClose={() => setShowLeavingRoadmapModal(null)} onClose={() => setShowLeavingRoadmapModal(null)}
onContinue={() => { repositoryUrl={showLeavingRoadmapModal?.repositoryUrl!}
const visitedSolutions = {
...alreadyVisitedSolutions,
[showLeavingRoadmapModal._id!]: true,
};
localStorage.setItem(
VISITED_SOLUTIONS_KEY,
JSON.stringify(visitedSolutions),
);
window.open(showLeavingRoadmapModal.repositoryUrl, '_blank');
}}
/> />
) : null; ) : null;
const selectedLanguage = pageState.language;
return ( return (
<section> <div className="mb-4 overflow-hidden rounded-lg border bg-white p-3 sm:p-5">
{leavingRoadmapModal} {leavingRoadmapModal}
<div className="relative mb-5 hidden items-center justify-between sm:flex">
<div>
<h1 className="mb-1 text-xl font-semibold">
{projectData.title} Solutions
</h1>
<p className="text-sm text-gray-500">{projectData.description}</p>
</div>
{!isLoading && (
<SelectLanguages
projectId={projectId}
selectedLanguage={selectedLanguage}
onSelectLanguage={(language) => {
setPageState((prev) => ({
...prev,
language: prev.language === language ? '' : language,
}));
}}
/>
)}
</div>
<div className="flex min-h-[500px] flex-col divide-y divide-gray-100"> {isLoading ? (
{solutions?.data.map((solution, counter) => { <LoadingSolutions />
const isVisited = alreadyVisitedSolutions[solution._id!]; ) : (
const avatar = solution.user.avatar || ''; <>
<div className="flex min-h-[500px] flex-col divide-y divide-gray-100">
return ( {solutions?.data.map((solution, counter) => {
<div const avatar = solution.user.avatar || '';
key={solution._id} return (
className={ <div
'flex flex-col justify-between gap-2 py-2 text-sm text-gray-500 sm:flex-row sm:items-center sm:gap-0' key={solution._id}
} className="flex flex-col gap-2 py-2 text-sm text-gray-500"
>
<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[
counter % 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="flex items-center justify-end gap-1">
<span className="flex 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');
}}
/>
</span>
<a
className="ml-1 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"
onClick={(e) => {
e.preventDefault();
setShowLeavingRoadmapModal(solution);
}}
target="_blank"
href={solution.repositoryUrl}
> >
<GitHubIcon className="h-4 w-4 text-current" /> <div className="flex flex-col justify-between gap-2 text-sm text-gray-500 sm:flex-row sm:items-center sm:gap-0">
Visit Solution <div className="flex items-center gap-1.5">
</a> <img
</div> 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[
counter % 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="flex items-center justify-end gap-1">
<span 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');
}}
/>
</span>
<button
className="ml-1 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"
onClick={() => {
setShowLeavingRoadmapModal(solution);
}}
>
<GitHubIcon className="h-4 w-4 text-current" />
Visit Solution
</button>
</div>
</div>
</div>
);
})}
</div>
{(solutions?.totalPages || 0) > 1 && (
<div className="mt-4">
<Pagination
totalPages={solutions?.totalPages || 1}
currPage={solutions?.currPage || 1}
perPage={solutions?.perPage || 21}
totalCount={solutions?.totalCount || 0}
onPageChange={(page) => {
setPageState({
...pageState,
currentPage: page,
});
}}
/>
</div> </div>
); )}
})} </>
</div>
{(solutions?.totalPages || 0) > 1 && (
<div className="mt-4">
<Pagination
totalPages={solutions?.totalPages || 1}
currPage={solutions?.currPage || 1}
perPage={solutions?.perPage || 21}
totalCount={solutions?.totalCount || 0}
onPageChange={(page) => {
setPageState({
...pageState,
currentPage: page,
});
}}
/>
</div>
)} )}
</section> </div>
); );
} }

@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import { useOutsideClick } from '../../hooks/use-outside-click';
import { httpGet } from '../../lib/http';
import { useToast } from '../../hooks/use-toast';
import { ChevronDown, X } from 'lucide-react';
type SelectLanguagesProps = {
projectId: string;
selectedLanguage: string;
onSelectLanguage: (language: string) => void;
};
export function SelectLanguages(props: SelectLanguagesProps) {
const { projectId, onSelectLanguage, selectedLanguage } = props;
const dropdownRef = useRef<HTMLDivElement>(null);
const toast = useToast();
const [distinctLanguages, setDistinctLanguages] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState(false);
const loadDistinctLanguages = async () => {
const { response, error } = await httpGet<string[]>(
`${import.meta.env.PUBLIC_API_URL}/v1-list-project-languages/${projectId}`,
);
if (error || !response) {
toast.error(error?.message || 'Failed to load project languages');
return;
}
setDistinctLanguages(response);
};
useOutsideClick(dropdownRef, () => {
setIsOpen(false);
});
useEffect(() => {
loadDistinctLanguages().finally(() => {});
}, []);
return (
<div className="relative flex">
<button
className="flex items-center gap-1 rounded-md border border-gray-300 py-1.5 pl-3 pr-2 text-xs font-medium text-gray-900"
onClick={() => setIsOpen(!isOpen)}
>
{selectedLanguage || 'Select Language'}
<ChevronDown className="ml-1 h-4 w-4" />
</button>
{selectedLanguage && (
<button
className="ml-1 text-red-500 text-xs border border-red-500 rounded-md px-2 py-1"
onClick={() => onSelectLanguage('')}
>
Clear
</button>
)}
{isOpen && (
<div
className="absolute right-0 top-full z-10 w-full min-w-[200px] max-w-[200px] translate-y-1.5 overflow-hidden rounded-md border border-gray-300 bg-white p-1 shadow-lg"
ref={dropdownRef}
>
{distinctLanguages.map((language) => {
const isSelected = selectedLanguage === language;
return (
<button
key={language}
className="flex w-full items-center rounded-md px-4 py-1.5 text-left text-sm text-gray-700 hover:bg-gray-100 aria-selected:bg-gray-100"
onClick={() => {
onSelectLanguage(language);
setIsOpen(false);
}}
aria-selected={isSelected}
>
{language}
</button>
);
})}
</div>
)}
</div>
);
}

@ -170,10 +170,19 @@ export function SubmitProjectModal(props: SubmitProjectModalProps) {
projectUrlExists: 'success', projectUrlExists: 'success',
}); });
const languagesUrl = `${mainApiUrl}/languages`;
const languagesResponse = await fetch(languagesUrl);
let languages: string[] = [];
if (languagesResponse.ok) {
const languagesData = await languagesResponse.json();
languages = Object.keys(languagesData || {})?.slice(0, 4);
}
const submitProjectUrl = `${import.meta.env.PUBLIC_API_URL}/v1-submit-project/${projectId}`; const submitProjectUrl = `${import.meta.env.PUBLIC_API_URL}/v1-submit-project/${projectId}`;
const { response: submitResponse, error } = const { response: submitResponse, error } =
await httpPost<SubmitProjectResponse>(submitProjectUrl, { await httpPost<SubmitProjectResponse>(submitProjectUrl, {
repositoryUrl: repoUrl, repositoryUrl: repoUrl,
languages,
}); });
if (error || !submitResponse) { if (error || !submitResponse) {
@ -272,7 +281,7 @@ export function SubmitProjectModal(props: SubmitProjectModalProps) {
<button <button
type="submit" type="submit"
className="mt-2 w-full rounded-lg bg-black p-2 font-medium text-white disabled:opacity-50 text-sm" className="mt-2 w-full rounded-lg bg-black p-2 text-sm font-medium text-white disabled:opacity-50"
disabled={isLoading} disabled={isLoading}
> >
{isLoading ? 'Verifying...' : 'Verify and Submit'} {isLoading ? 'Verifying...' : 'Verify and Submit'}

@ -49,18 +49,11 @@ const githubUrl = `https://github.com/kamranahmedse/developer-roadmap/tree/maste
<div class='container'> <div class='container'>
<ProjectTabs projectId={projectId} activeTab='solutions' /> <ProjectTabs projectId={projectId} activeTab='solutions' />
<div class='mb-4 overflow-hidden rounded-lg border bg-white p-3 sm:p-5'> <ListProjectSolutions
<div class='relative mb-5 hidden sm:block'> project={projectData}
<h1 class='mb-1 text-xl font-semibold'> projectId={projectId}
{projectData.title} Solutions client:load
</h1> />
<p class='text-sm text-gray-500'>
{projectData.description}
</p>
</div>
<ListProjectSolutions projectId={projectId} client:load />
</div>
</div> </div>
</div> </div>
</BaseLayout> </BaseLayout>

Loading…
Cancel
Save