|
|
|
@ -13,41 +13,7 @@ import { isLoggedIn } from '../../lib/jwt'; |
|
|
|
|
import { showLoginPopup } from '../../lib/popup'; |
|
|
|
|
import { VoteButton } from './VoteButton.tsx'; |
|
|
|
|
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx'; |
|
|
|
|
import { cn } from '../../lib/classname.ts'; |
|
|
|
|
|
|
|
|
|
const languageColors = new Map([ |
|
|
|
|
['JavaScript', 'bg-[#f1e05a]'], |
|
|
|
|
['Python', 'bg-[#3572A5]'], |
|
|
|
|
['Java', 'bg-[#b07219]'], |
|
|
|
|
['HTML', 'bg-[#e34c26]'], |
|
|
|
|
['CSS', 'bg-[#563d7c]'], |
|
|
|
|
['C++', 'bg-[#f34b7d]'], |
|
|
|
|
['C', 'bg-[#555555]'], |
|
|
|
|
['Go', 'bg-[#00ADD8]'], |
|
|
|
|
['TypeScript', 'bg-[#2b7489]'], |
|
|
|
|
['Shell', 'bg-[#89e051]'], |
|
|
|
|
['Ruby', 'bg-[#701516]'], |
|
|
|
|
['PHP', 'bg-[#4F5D95]'], |
|
|
|
|
['Rust', 'bg-[#dea584]'], |
|
|
|
|
['Swift', 'bg-[#ffac45]'], |
|
|
|
|
['Kotlin', 'bg-[#A97BFF]'], |
|
|
|
|
['Dart', 'bg-[#00B4AB]'], |
|
|
|
|
['Scala', 'bg-[#c22d40]'], |
|
|
|
|
['Objective-C', 'bg-[#438eff]'], |
|
|
|
|
['Vue', 'bg-[#41b883]'], |
|
|
|
|
['R', 'bg-[#198CE7]'], |
|
|
|
|
['Perl', 'bg-[#0298c3]'], |
|
|
|
|
['Haskell', 'bg-[#5e5086]'], |
|
|
|
|
['Lua', 'bg-[#000080]'], |
|
|
|
|
['Matlab', 'bg-[#e16737]'], |
|
|
|
|
['Vim script', 'bg-[#199f4b]'], |
|
|
|
|
['Elixir', 'bg-[#6e4a7e]'], |
|
|
|
|
['Erlang', 'bg-[#B83998]'], |
|
|
|
|
['Clojure', 'bg-[#db5855]'], |
|
|
|
|
['Markdown', 'bg-[#083fa1]'], |
|
|
|
|
['TeX', 'bg-[#3D6117]'], |
|
|
|
|
['SQL', 'bg-[#e38c00]'], |
|
|
|
|
]); |
|
|
|
|
import { SelectLanguages } from './SelectLanguages.tsx'; |
|
|
|
|
|
|
|
|
|
export interface ProjectStatusDocument { |
|
|
|
|
_id?: string; |
|
|
|
@ -88,10 +54,12 @@ type ListProjectSolutionsResponse = { |
|
|
|
|
|
|
|
|
|
type QueryParams = { |
|
|
|
|
p?: string; |
|
|
|
|
l?: string; |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
type PageState = { |
|
|
|
|
currentPage: number; |
|
|
|
|
languages: string[]; |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const VISITED_SOLUTIONS_KEY = 'visited-project-solutions'; |
|
|
|
@ -130,22 +98,22 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) { |
|
|
|
|
const toast = useToast(); |
|
|
|
|
const [pageState, setPageState] = useState<PageState>({ |
|
|
|
|
currentPage: 0, |
|
|
|
|
languages: [], |
|
|
|
|
}); |
|
|
|
|
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true); |
|
|
|
|
const [solutions, setSolutions] = useState<ListProjectSolutionsResponse>(); |
|
|
|
|
const [alreadyVisitedSolutions, setAlreadyVisitedSolutions] = useState< |
|
|
|
|
Record<string, boolean> |
|
|
|
|
>({}); |
|
|
|
|
const [showLeavingRoadmapModal, setShowLeavingRoadmapModal] = useState< |
|
|
|
|
ListProjectSolutionsResponse['data'][number] | null |
|
|
|
|
>(null); |
|
|
|
|
const [distinctLanguages, setDistinctLanguages] = useState<string[]>([]); |
|
|
|
|
|
|
|
|
|
const loadSolutions = async (page = 1) => { |
|
|
|
|
const loadSolutions = async (page = 1, languages: string[] = []) => { |
|
|
|
|
const { response, error } = await httpGet<ListProjectSolutionsResponse>( |
|
|
|
|
`${import.meta.env.PUBLIC_API_URL}/v1-list-project-solutions/${projectId}`, |
|
|
|
|
{ |
|
|
|
|
currPage: page, |
|
|
|
|
...(languages.length > 0 ? { languages: languages.join(',') } : {}), |
|
|
|
|
}, |
|
|
|
|
); |
|
|
|
|
|
|
|
|
@ -158,6 +126,19 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) { |
|
|
|
|
setSolutions(response); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
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); |
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
const handleSubmitVote = async ( |
|
|
|
|
solutionId: string, |
|
|
|
|
voteType: AllowedVoteType, |
|
|
|
@ -207,13 +188,9 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) { |
|
|
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
|
const queryParams = getUrlParams() as QueryParams; |
|
|
|
|
const alreadyVisitedSolutions = JSON.parse( |
|
|
|
|
localStorage.getItem(VISITED_SOLUTIONS_KEY) || '{}', |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
setAlreadyVisitedSolutions(alreadyVisitedSolutions); |
|
|
|
|
setPageState({ |
|
|
|
|
currentPage: +(queryParams.p || '1'), |
|
|
|
|
languages: (queryParams.l || '').split(',').filter(Boolean), |
|
|
|
|
}); |
|
|
|
|
}, []); |
|
|
|
|
|
|
|
|
@ -223,22 +200,24 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) { |
|
|
|
|
return; |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
if (pageState.currentPage !== 1) { |
|
|
|
|
if (pageState.currentPage !== 1 || pageState.languages.length > 0) { |
|
|
|
|
setUrlParams({ |
|
|
|
|
p: String(pageState.currentPage), |
|
|
|
|
l: pageState.languages.join(','), |
|
|
|
|
}); |
|
|
|
|
} else { |
|
|
|
|
deleteUrlParam('p'); |
|
|
|
|
deleteUrlParam('l'); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
loadSolutions(pageState.currentPage).finally(() => { |
|
|
|
|
loadSolutions(pageState.currentPage, pageState.languages).finally(() => { |
|
|
|
|
setIsLoading(false); |
|
|
|
|
}); |
|
|
|
|
}, [pageState]); |
|
|
|
|
|
|
|
|
|
if (isLoading) { |
|
|
|
|
return <LoadingSolutions />; |
|
|
|
|
} |
|
|
|
|
useEffect(() => { |
|
|
|
|
loadDistinctLanguages().finally(() => {}); |
|
|
|
|
}, []); |
|
|
|
|
|
|
|
|
|
const isEmpty = solutions?.data.length === 0; |
|
|
|
|
if (isEmpty) { |
|
|
|
@ -248,132 +227,122 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) { |
|
|
|
|
const leavingRoadmapModal = showLeavingRoadmapModal ? ( |
|
|
|
|
<LeavingRoadmapWarningModal |
|
|
|
|
onClose={() => setShowLeavingRoadmapModal(null)} |
|
|
|
|
onContinue={() => { |
|
|
|
|
const visitedSolutions = { |
|
|
|
|
...alreadyVisitedSolutions, |
|
|
|
|
[showLeavingRoadmapModal._id!]: true, |
|
|
|
|
}; |
|
|
|
|
localStorage.setItem( |
|
|
|
|
VISITED_SOLUTIONS_KEY, |
|
|
|
|
JSON.stringify(visitedSolutions), |
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
window.open(showLeavingRoadmapModal.repositoryUrl, '_blank'); |
|
|
|
|
}} |
|
|
|
|
repositoryUrl={showLeavingRoadmapModal?.repositoryUrl!} |
|
|
|
|
/> |
|
|
|
|
) : null; |
|
|
|
|
|
|
|
|
|
const selectedLanguages = pageState.languages; |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<section> |
|
|
|
|
{leavingRoadmapModal} |
|
|
|
|
|
|
|
|
|
<div className="flex min-h-[500px] flex-col divide-y divide-gray-100"> |
|
|
|
|
{solutions?.data.map((solution, counter) => { |
|
|
|
|
const avatar = solution.user.avatar || ''; |
|
|
|
|
const languages = solution?.languages || []; |
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
<div |
|
|
|
|
key={solution._id} |
|
|
|
|
className="flex flex-col gap-2 py-2 text-sm text-gray-500" |
|
|
|
|
> |
|
|
|
|
<div className="flex flex-col justify-between gap-2 text-sm text-gray-500 sm:flex-row sm:items-center sm:gap-0"> |
|
|
|
|
<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" /> |
|
|
|
|
Visit Solution |
|
|
|
|
</a> |
|
|
|
|
<SelectLanguages |
|
|
|
|
languages={distinctLanguages} |
|
|
|
|
selectedLanguages={pageState.languages} |
|
|
|
|
onSelectLanguage={(language) => { |
|
|
|
|
const isAlreadySelected = selectedLanguages.includes(language); |
|
|
|
|
const newLanguages = isAlreadySelected |
|
|
|
|
? selectedLanguages.filter((l) => l !== language) |
|
|
|
|
: [...selectedLanguages, language]; |
|
|
|
|
|
|
|
|
|
setPageState({ |
|
|
|
|
...pageState, |
|
|
|
|
languages: newLanguages, |
|
|
|
|
}); |
|
|
|
|
}} |
|
|
|
|
/> |
|
|
|
|
|
|
|
|
|
{isLoading ? ( |
|
|
|
|
<LoadingSolutions /> |
|
|
|
|
) : ( |
|
|
|
|
<> |
|
|
|
|
<div className="flex min-h-[500px] flex-col divide-y divide-gray-100"> |
|
|
|
|
{solutions?.data.map((solution, counter) => { |
|
|
|
|
const avatar = solution.user.avatar || ''; |
|
|
|
|
return ( |
|
|
|
|
<div |
|
|
|
|
key={solution._id} |
|
|
|
|
className="flex flex-col gap-2 py-2 text-sm text-gray-500" |
|
|
|
|
> |
|
|
|
|
<div className="flex flex-col justify-between gap-2 text-sm text-gray-500 sm:flex-row sm:items-center sm:gap-0"> |
|
|
|
|
<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 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> |
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-2.5"> |
|
|
|
|
{languages.map((language) => ( |
|
|
|
|
<span |
|
|
|
|
key={language} |
|
|
|
|
className="flex items-center gap-2 text-sm" |
|
|
|
|
> |
|
|
|
|
<span |
|
|
|
|
className={cn( |
|
|
|
|
'h-2 w-2 rounded-full', |
|
|
|
|
languageColors.get(language) || 'bg-gray-400', |
|
|
|
|
)} |
|
|
|
|
/> |
|
|
|
|
{language} |
|
|
|
|
</span> |
|
|
|
|
))} |
|
|
|
|
</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> |
|
|
|
|
|
|
|
|
|
{(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> |
|
|
|
|
); |
|
|
|
|