feat: official project
This commit is contained in:
1 parent
2d18cefd55
commit
bb0419bf8a
14 files changed
+244
-286
No files matched your search
@@ -3,21 +3,16 @@ import { useToast } from '../../hooks/use-toast';
|
||||
import { httpGet, httpPost } from '../../lib/http';
|
||||
import { LoadingSolutions } from './LoadingSolutions';
|
||||
import { EmptySolutions } from './EmptySolutions';
|
||||
import { ThumbsDown, ThumbsUp } from 'lucide-react';
|
||||
import { getRelativeTimeString } from '../../lib/date';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import { deleteUrlParam, getUrlParams, setUrlParams } from '../../lib/browser';
|
||||
import { pageProgressMessage } from '../../stores/page';
|
||||
import { LeavingRoadmapWarningModal } from './LeavingRoadmapWarningModal';
|
||||
import { isLoggedIn } from '../../lib/jwt';
|
||||
import { showLoginPopup } from '../../lib/popup';
|
||||
import { VoteButton } from './VoteButton.tsx';
|
||||
import { GitHubIcon } from '../ReactIcons/GitHubIcon.tsx';
|
||||
import { SelectLanguages } from './SelectLanguages.tsx';
|
||||
import type { ProjectFrontmatter } from '../../lib/project.ts';
|
||||
import { ProjectSolutionModal } from './ProjectSolutionModal.tsx';
|
||||
import { SortProjects } from './SortProjects.tsx';
|
||||
import { ProjectSolutionRow } from './ProjectSolutionRow';
|
||||
import type { OfficialProjectDocument } from '../../queries/official-project.ts';
|
||||
|
||||
export interface ProjectStatusDocument {
|
||||
_id?: string;
|
||||
@@ -69,12 +64,12 @@ type PageState = {
|
||||
};
|
||||
|
||||
type ListProjectSolutionsProps = {
|
||||
project: ProjectFrontmatter;
|
||||
project: OfficialProjectDocument;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export function ListProjectSolutions(props: ListProjectSolutionsProps) {
|
||||
const { projectId, project: projectData } = props;
|
||||
const { projectId, project } = props;
|
||||
|
||||
const toast = useToast();
|
||||
const [pageState, setPageState] = useState<PageState>({
|
||||
@@ -226,7 +221,7 @@ export function ListProjectSolutions(props: ListProjectSolutionsProps) {
|
||||
<div className="relative mb-5 hidden items-center justify-between sm:flex">
|
||||
<div>
|
||||
<h1 className="mb-1 text-xl font-semibold">
|
||||
{projectData.title} Solutions
|
||||
{project.title} Solutions
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Solutions submitted by the community
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
import { Badge } from '../Badge.tsx';
|
||||
import type {
|
||||
ProjectDifficultyType,
|
||||
ProjectFileType,
|
||||
} from '../../lib/project.ts';
|
||||
import { Users } from 'lucide-react';
|
||||
import { formatCommaNumber } from '../../lib/number.ts';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import type {
|
||||
AllowedOfficialProjectDifficulty,
|
||||
OfficialProjectDocument,
|
||||
} from '../../queries/official-project.ts';
|
||||
|
||||
type ProjectCardProps = {
|
||||
project: ProjectFileType;
|
||||
project: OfficialProjectDocument;
|
||||
userCount?: number;
|
||||
status?: 'completed' | 'started' | 'none';
|
||||
};
|
||||
|
||||
const badgeVariants: Record<ProjectDifficultyType, string> = {
|
||||
const badgeVariants: Record<AllowedOfficialProjectDifficulty, string> = {
|
||||
beginner: 'yellow',
|
||||
intermediate: 'green',
|
||||
advanced: 'blue',
|
||||
@@ -22,26 +21,24 @@ const badgeVariants: Record<ProjectDifficultyType, string> = {
|
||||
|
||||
export function ProjectCard(props: ProjectCardProps) {
|
||||
const { project, userCount = 0, status } = props;
|
||||
const { frontmatter, id } = project;
|
||||
const { difficulty, title, description, slug, topics = [] } = project;
|
||||
|
||||
const isLoadingStatus = status === undefined;
|
||||
const userStartedCount = status !== 'none' && userCount === 0 ? userCount + 1 : userCount;
|
||||
const userStartedCount =
|
||||
status !== 'none' && userCount === 0 ? userCount + 1 : userCount;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/projects/${id}`}
|
||||
href={`/projects/${slug}`}
|
||||
className="flex flex-col rounded-md border bg-white p-3 transition-colors hover:border-gray-300 hover:bg-gray-50"
|
||||
>
|
||||
<span className="flex justify-between gap-1.5">
|
||||
<Badge
|
||||
variant={badgeVariants[frontmatter.difficulty] as any}
|
||||
text={frontmatter.difficulty}
|
||||
/>
|
||||
<Badge variant={'grey'} text={frontmatter.nature} />
|
||||
<Badge variant={badgeVariants[difficulty] as any} text={difficulty} />
|
||||
{topics?.map((topic) => <Badge variant={'grey'} text={topic} />)}
|
||||
</span>
|
||||
<span className="my-3 flex min-h-[100px] flex-col">
|
||||
<span className="mb-1 font-medium">{frontmatter.title}</span>
|
||||
<span className="text-sm text-gray-500">{frontmatter.description}</span>
|
||||
<span className="mb-1 font-medium">{title}</span>
|
||||
<span className="text-sm text-gray-500">{description}</span>
|
||||
</span>
|
||||
<span className="flex min-h-[22px] items-center justify-between gap-2 text-xs text-gray-400">
|
||||
{isLoadingStatus ? (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { guideRenderer } from '../../lib/guide-renderer';
|
||||
import type { OfficialProjectDocument } from '../../queries/official-project';
|
||||
|
||||
type ProjectContentProps = {
|
||||
project: OfficialProjectDocument;
|
||||
};
|
||||
|
||||
export function ProjectContent(props: ProjectContentProps) {
|
||||
const { project } = props;
|
||||
|
||||
const isContentString = typeof project?.content === 'string';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="prose prose-h2:mb-3 prose-h2:mt-5 prose-h3:mb-1 prose-h3:mt-5 prose-p:mb-2 prose-blockquote:font-normal prose-blockquote:text-gray-500 prose-pre:my-3 prose-ul:my-3.5 prose-hr:my-5 prose-li:[&>p]:m-0 max-w-full [&>ul>li]:my-1"
|
||||
{...(isContentString
|
||||
? {
|
||||
dangerouslySetInnerHTML: { __html: project?.content },
|
||||
}
|
||||
: {
|
||||
children: guideRenderer.render(project?.content),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,7 @@ import { ProjectCard } from './ProjectCard.tsx';
|
||||
import { HeartHandshake, Trash2 } from 'lucide-react';
|
||||
import { cn } from '../../lib/classname.ts';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
projectDifficulties,
|
||||
type ProjectDifficultyType,
|
||||
type ProjectFileType,
|
||||
} from '../../lib/project.ts';
|
||||
|
||||
import {
|
||||
deleteUrlParam,
|
||||
getUrlParams,
|
||||
@@ -14,9 +10,14 @@ import {
|
||||
} from '../../lib/browser.ts';
|
||||
import { httpPost } from '../../lib/http.ts';
|
||||
import { isLoggedIn } from '../../lib/jwt.ts';
|
||||
import {
|
||||
allowedOfficialProjectDifficulty,
|
||||
type AllowedOfficialProjectDifficulty,
|
||||
type OfficialProjectDocument,
|
||||
} from '../../queries/official-project.ts';
|
||||
|
||||
type DifficultyButtonProps = {
|
||||
difficulty: ProjectDifficultyType;
|
||||
difficulty: AllowedOfficialProjectDifficulty;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
};
|
||||
@@ -46,7 +47,7 @@ export type ListProjectStatusesResponse = Record<
|
||||
>;
|
||||
|
||||
type ProjectsListProps = {
|
||||
projects: ProjectFileType[];
|
||||
projects: OfficialProjectDocument[];
|
||||
userCounts: Record<string, number>;
|
||||
};
|
||||
|
||||
@@ -55,7 +56,7 @@ export function ProjectsList(props: ProjectsListProps) {
|
||||
|
||||
const { difficulty: urlDifficulty } = getUrlParams();
|
||||
const [difficulty, setDifficulty] = useState<
|
||||
ProjectDifficultyType | undefined
|
||||
AllowedOfficialProjectDifficulty | undefined
|
||||
>(urlDifficulty);
|
||||
const [projectStatuses, setProjectStatuses] =
|
||||
useState<ListProjectStatusesResponse>();
|
||||
@@ -66,7 +67,7 @@ export function ProjectsList(props: ProjectsListProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectIds = projects.map((project) => project.id);
|
||||
const projectIds = projects.map((project) => project.slug);
|
||||
const { response, error } = await httpPost(
|
||||
`${import.meta.env.PUBLIC_API_URL}/v1-list-project-statuses`,
|
||||
{
|
||||
@@ -82,22 +83,27 @@ export function ProjectsList(props: ProjectsListProps) {
|
||||
setProjectStatuses(response);
|
||||
};
|
||||
|
||||
const projectsByDifficulty: Map<ProjectDifficultyType, ProjectFileType[]> =
|
||||
useMemo(() => {
|
||||
const result = new Map<ProjectDifficultyType, ProjectFileType[]>();
|
||||
const projectsByDifficulty: Map<
|
||||
AllowedOfficialProjectDifficulty,
|
||||
OfficialProjectDocument[]
|
||||
> = useMemo(() => {
|
||||
const result = new Map<
|
||||
AllowedOfficialProjectDifficulty,
|
||||
OfficialProjectDocument[]
|
||||
>();
|
||||
|
||||
for (const project of projects) {
|
||||
const difficulty = project.frontmatter.difficulty;
|
||||
for (const project of projects) {
|
||||
const difficulty = project.difficulty;
|
||||
|
||||
if (!result.has(difficulty)) {
|
||||
result.set(difficulty, []);
|
||||
}
|
||||
|
||||
result.get(difficulty)?.push(project);
|
||||
if (!result.has(difficulty)) {
|
||||
result.set(difficulty, []);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [projects]);
|
||||
result.get(difficulty)?.push(project);
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [projects]);
|
||||
|
||||
const matchingProjects = difficulty
|
||||
? projectsByDifficulty.get(difficulty) || []
|
||||
@@ -111,7 +117,7 @@ export function ProjectsList(props: ProjectsListProps) {
|
||||
<div className="flex flex-col">
|
||||
<div className="my-2.5 flex items-center justify-between">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{projectDifficulties.map((projectDifficulty) => (
|
||||
{allowedOfficialProjectDifficulty.map((projectDifficulty) => (
|
||||
<DifficultyButton
|
||||
key={projectDifficulty}
|
||||
onClick={() => {
|
||||
@@ -122,6 +128,7 @@ export function ProjectsList(props: ProjectsListProps) {
|
||||
isActive={projectDifficulty === difficulty}
|
||||
/>
|
||||
))}
|
||||
|
||||
{difficulty && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -155,25 +162,25 @@ export function ProjectsList(props: ProjectsListProps) {
|
||||
|
||||
{matchingProjects
|
||||
.sort((project) => {
|
||||
return project.frontmatter.difficulty === 'beginner'
|
||||
return project.difficulty === 'beginner'
|
||||
? -1
|
||||
: project.frontmatter.difficulty === 'intermediate'
|
||||
: project.difficulty === 'intermediate'
|
||||
? 0
|
||||
: 1;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return a.frontmatter.sort - b.frontmatter.sort;
|
||||
return a.order - b.order;
|
||||
})
|
||||
.map((matchingProject) => {
|
||||
const count = userCounts[matchingProject?.id] || 0;
|
||||
const count = userCounts[matchingProject?.slug] || 0;
|
||||
return (
|
||||
<ProjectCard
|
||||
key={matchingProject.id}
|
||||
key={matchingProject.slug}
|
||||
project={matchingProject}
|
||||
userCount={count}
|
||||
status={
|
||||
projectStatuses
|
||||
? (projectStatuses?.[matchingProject.id] || 'none')
|
||||
? projectStatuses?.[matchingProject.slug] || 'none'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -7,16 +7,16 @@ import {
|
||||
setUrlParams,
|
||||
} from '../../lib/browser.ts';
|
||||
import { CategoryFilterButton } from '../Roadmaps/CategoryFilterButton.tsx';
|
||||
import {
|
||||
projectDifficulties,
|
||||
type ProjectFileType,
|
||||
} from '../../lib/project.ts';
|
||||
import { ProjectCard } from './ProjectCard.tsx';
|
||||
import {
|
||||
allowedOfficialProjectDifficulty,
|
||||
type OfficialProjectDocument,
|
||||
} from '../../queries/official-project.ts';
|
||||
|
||||
type ProjectGroup = {
|
||||
id: string;
|
||||
title: string;
|
||||
projects: ProjectFileType[];
|
||||
projects: OfficialProjectDocument[];
|
||||
};
|
||||
|
||||
type ProjectsPageProps = {
|
||||
@@ -28,7 +28,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
const { roadmapsProjects, userCounts } = props;
|
||||
const allUniqueProjectIds = new Set<string>(
|
||||
roadmapsProjects.flatMap((group) =>
|
||||
group.projects.map((project) => project.id),
|
||||
group.projects.map((project) => project.slug),
|
||||
),
|
||||
);
|
||||
const allUniqueProjects = useMemo(
|
||||
@@ -37,15 +37,15 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
.map((id) =>
|
||||
roadmapsProjects
|
||||
.flatMap((group) => group.projects)
|
||||
.find((project) => project.id === id),
|
||||
.find((project) => project.slug === id),
|
||||
)
|
||||
.filter(Boolean) as ProjectFileType[],
|
||||
.filter(Boolean) as OfficialProjectDocument[],
|
||||
[allUniqueProjectIds],
|
||||
);
|
||||
|
||||
const [activeGroup, setActiveGroup] = useState<string>('');
|
||||
const [visibleProjects, setVisibleProjects] =
|
||||
useState<ProjectFileType[]>(allUniqueProjects);
|
||||
useState<OfficialProjectDocument[]>(allUniqueProjects);
|
||||
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
|
||||
@@ -67,11 +67,11 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
const sortedVisibleProjects = useMemo(
|
||||
() =>
|
||||
visibleProjects.sort((a, b) => {
|
||||
const projectADifficulty = a?.frontmatter.difficulty || 'beginner';
|
||||
const projectBDifficulty = b?.frontmatter.difficulty || 'beginner';
|
||||
const projectADifficulty = a?.difficulty || 'beginner';
|
||||
const projectBDifficulty = b?.difficulty || 'beginner';
|
||||
return (
|
||||
projectDifficulties.indexOf(projectADifficulty) -
|
||||
projectDifficulties.indexOf(projectBDifficulty)
|
||||
allowedOfficialProjectDifficulty.indexOf(projectADifficulty) -
|
||||
allowedOfficialProjectDifficulty.indexOf(projectBDifficulty)
|
||||
);
|
||||
}),
|
||||
[visibleProjects],
|
||||
@@ -111,7 +111,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
{isFilterOpen && <X size={13} className="mr-1" />}
|
||||
Categories
|
||||
</button>
|
||||
<div className="container relative flex flex-col gap-4 sm:flex-row">
|
||||
<div className="relative container flex flex-col gap-4 sm:flex-row">
|
||||
<div
|
||||
className={cn(
|
||||
'hidden w-full flex-col from-gray-100 sm:w-[160px] sm:shrink-0 sm:border-r sm:bg-linear-to-l sm:pt-6',
|
||||
@@ -171,7 +171,7 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex grow flex-col pb-20 pt-2 sm:pt-6">
|
||||
<div className="flex grow flex-col pt-2 pb-20 sm:pt-6">
|
||||
<div className="mb-4 flex items-center justify-between text-sm text-gray-500">
|
||||
<h3 className={'flex items-center'}>
|
||||
<Box size={15} className="mr-1" strokeWidth={2} />
|
||||
@@ -187,9 +187,9 @@ export function ProjectsPage(props: ProjectsPageProps) {
|
||||
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
|
||||
{sortedVisibleProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
key={project.slug}
|
||||
project={project}
|
||||
userCount={userCounts[project.id] || 0}
|
||||
userCount={userCounts[project.slug] || 0}
|
||||
status={'none'}
|
||||
/>
|
||||
))}
|
||||
|
||||
Reference in new issue
Block a user