Skip to content

Commit

Permalink
feat(teams, projects): implement create and read
Browse files Browse the repository at this point in the history
  • Loading branch information
TiveCS committed Dec 13, 2024
1 parent ef47f7e commit 06208b1
Show file tree
Hide file tree
Showing 38 changed files with 5,262 additions and 1,079 deletions.
6 changes: 5 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 1,7 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
"extends": [
"next/core-web-vitals",
"next/typescript",
"plugin:@tanstack/query/recommended"
]
}
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 1 @@
public-hoist-pattern[]=*@nextui-org/*
47 changes: 47 additions & 0 deletions actions/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 1,47 @@
'use server';

import { createProject, findProjectsByTeamId } from '@/db/repo/projects';
import { findTeamByIdAndMemberId } from '@/db/repo/teams';
import { TeamError } from '@/errors/teams';
import { authedClient } from '@/lib/server/clients';
import { createProjectSchema } from '@/models/projects';
import { teamIdSchema } from '@/models/teams';

export const actionGetTeamProjects = authedClient
.schema(teamIdSchema)
.action(async ({ ctx, parsedInput: teamId }) => {
const { user } = ctx;

const team = await findTeamByIdAndMemberId(teamId, user.id);

if (team.length === 0) throw new Error(TeamError.NotFound);

const projects = await findProjectsByTeamId(teamId);

return projects;
});

export const actionCreateProject = authedClient
.bindArgsSchemas([teamIdSchema.nullable()])
.schema(createProjectSchema)
.action(
async ({
ctx: { user },
parsedInput: dto,
bindArgsParsedInputs: [teamId],
}) => {
if (!teamId) throw new Error(TeamError.BadRequestTeamIdNotProvided);

const team = await findTeamByIdAndMemberId(teamId, user.id);

if (team.length === 0) throw new Error(TeamError.NotFound);

const project = await createProject({
teamId,
name: dto.name,
description: dto.description,
});

return project;
}
);
18 changes: 18 additions & 0 deletions actions/teams.ts
Original file line number Diff line number Diff line change
@@ -0,0 1,18 @@
'use server';

import { createTeam, findTeamsByMemberId } from '@/db/repo/teams';
import { authedClient } from '@/lib/server/clients';
import { mutateTeamSchema } from '@/models/teams';

export const actionGetUserTeams = authedClient.action(async ({ ctx }) => {
return await findTeamsByMemberId(ctx.user.id);
});

export const actionCreateTeam = authedClient
.schema(mutateTeamSchema)
.action(async ({ ctx, parsedInput: data }) => {
return await createTeam({
name: data.name,
ownedBy: ctx.user.id,
});
});
29 changes: 29 additions & 0 deletions app/(app)/_components/app-navbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 1,29 @@
'use client';

import { Link } from '@nextui-org/link';
import { UserAvatar } from './user-avatar';

type AppNavbarProps = {
name: string;
avatarUrl: string;
};

export function AppNavbar({ name, avatarUrl }: AppNavbarProps) {
return (
<nav className="w-full grid grid-flow-col px-8 py-6">
<div className="grid grid-cols-6 gap-x-6">
<div>
<Link href="/">ReqLink</Link>
</div>

<div className="col-span-5 space-x-6">
<Link href="/projects">Projects</Link>
</div>
</div>

<div className="flex flex-row-reverse">
<UserAvatar name={name} url={avatarUrl} />
</div>
</nav>
);
}
27 changes: 27 additions & 0 deletions app/(app)/_components/user-avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 1,27 @@
'use client';

import { User } from '@nextui-org/user';
import { User2Icon } from 'lucide-react';

type UserAvatarProps = {
url: string;
name: string;
};

export function UserAvatar({ name, url }: UserAvatarProps) {
const initials = name.charAt(0).toUpperCase();

return (
<User
name={name}
isFocusable
className="flex-row-reverse"
avatarProps={{
src: url,
fallback: <User2Icon className="size-4" />,
showFallback: true,
name: initials,
}}
/>
);
}
22 changes: 22 additions & 0 deletions app/(app)/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 1,22 @@
import { getCurrentSession } from '@/auth/sessions';
import { redirect } from 'next/navigation';
import React from 'react';
import { AppNavbar } from './_components/app-navbar';

export default async function RootLayout({
children,
}: React.PropsWithChildren) {
const { user } = await getCurrentSession();

if (!user) {
return redirect('/login');
}

return (
<>
<AppNavbar name={user.name} avatarUrl={user.picture} />

<main className="flex-1 flex flex-col">{children}</main>
</>
);
}
19 changes: 19 additions & 0 deletions app/(app)/projects/_components/project-card-skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 1,19 @@
'use client';

import { Card, CardBody } from '@nextui-org/card';
import { Skeleton } from '@nextui-org/skeleton';

export function ProjectCardSkeleton() {
return (
<Card className="w-full bg-background">
<CardBody className="gap-y-8">
<Skeleton className="w-full h-20 rounded-lg" />

<div className="grid grid-cols-2 gap-x-8">
<Skeleton className="h-12 rounded-lg" />
<Skeleton className="h-12 rounded-lg" />
</div>
</CardBody>
</Card>
);
}
56 changes: 56 additions & 0 deletions app/(app)/projects/_components/project-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 1,56 @@
import { formatDistanceDate } from '@/lib/date';
import { Card, CardBody, CardFooter, CardHeader } from '@nextui-org/card';
import { Divider } from '@nextui-org/divider';
import Link from 'next/link';

type ProjectCardProps = {
projectId: string;
name: string;
lastUpdated: Date;
requirementSpecLeft: number;
backlogLeft: number;
};

export function ProjectCard({
projectId,
name,
backlogLeft,
lastUpdated,
requirementSpecLeft,
}: ProjectCardProps) {
return (
<Card
className="max-w-sm p-2 h-fit"
isPressable
isHoverable
as={Link}
href={`/projects/${projectId}`}
>
<CardHeader className="justify-between items-start">
<div className="text-left">
<h5 className="text-lg font-medium">{name}</h5>
<p className="text-sm text-default-500">
Last edit{' '}
{formatDistanceDate({ from: lastUpdated, to: new Date() })}
</p>
</div>
</CardHeader>

<CardBody>
<Divider />
</CardBody>

<CardFooter className="grid grid-cols-2 text-left gap-x-4">
<div>
<h6 className="text-sm text-default-500">Requirements Left</h6>
<p className="text-xl font-medium">{requirementSpecLeft}</p>
</div>

<div>
<h6 className="text-sm text-default-500">Backlog Left</h6>
<p className="text-xl font-medium">{backlogLeft}</p>
</div>
</CardFooter>
</Card>
);
}
18 changes: 18 additions & 0 deletions app/(app)/projects/_components/project-empty.tsx
Original file line number Diff line number Diff line change
@@ -0,0 1,18 @@
import { CreateProjectModal } from '@/components/projects/create-project-modal';

export function ProjectEmpty() {
return (
<div className="flex flex-col items-center justify-center space-y-4 w-full h-full flex-1">
<div className="text-center">
<h6 className="text-xl font-medium">There are no project here</h6>
<p className="text-default-500">
Getting started by create new project
</p>
</div>

<div>
<CreateProjectModal buttonClassName="block" />
</div>
</div>
);
}
67 changes: 67 additions & 0 deletions app/(app)/projects/_components/project-list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 1,67 @@
'use client';

import { useQueryGetTeamProjects } from '@/hooks/queries/use-get-team-projects';
import { useSafeContext } from '@/lib/utils';
import { TeamsContext } from '@/providers/teams-provider';
import { ProjectCard } from './project-card';
import { ProjectCardSkeleton } from './project-card-skeleton';
import { ProjectEmpty } from './project-empty';
import { ScrollShadow } from '@nextui-org/scroll-shadow';

export function ProjectList() {
const { selectedTeamId } = useSafeContext(TeamsContext);

const { data, isPending } = useQueryGetTeamProjects({
teamId: selectedTeamId,
});
const projects = data ?? [];

const isEmpty = projects.length === 0;

if (!isPending && isEmpty) return <ProjectEmpty />;

return (
<ScrollShadow className="px-32 py-12 max-h-[70svh]">
<div className="w-full grid grid-cols-3 gap-6">
{!isPending &&
projects.map((project) => (
<ProjectCard
key={project.id}
projectId={project.id}
name={project.name}
lastUpdated={project.updatedAt}
backlogLeft={project.backlogLeft}
requirementSpecLeft={project.requirementSpecLeft}
/>
))}
{!isPending &&
projects.map((project) => (
<ProjectCard
key={project.id}
projectId={project.id}
name={project.name}
lastUpdated={project.updatedAt}
backlogLeft={project.backlogLeft}
requirementSpecLeft={project.requirementSpecLeft}
/>
))}
{!isPending &&
projects.map((project) => (
<ProjectCard
key={project.id}
projectId={project.id}
name={project.name}
lastUpdated={project.updatedAt}
backlogLeft={project.backlogLeft}
requirementSpecLeft={project.requirementSpecLeft}
/>
))}

{isPending &&
Array.from({ length: 6 }).map((_, index) => (
<ProjectCardSkeleton key={index} />
))}
</div>
</ScrollShadow>
);
}
24 changes: 24 additions & 0 deletions app/(app)/projects/_components/team-navs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 1,24 @@
'use client';

import { CreateTeamModal } from '@/components/teams/create-team-modal';
import { TeamSelector } from '@/components/teams/team-selector';
import { useSafeContext } from '@/lib/utils';
import { TeamsContext } from '@/providers/teams-provider';

export function TeamNavs() {
const { getTeams, selectedTeamId, setSelectedTeamId } =
useSafeContext(TeamsContext);

return (
<div className="grid grid-flow-col items-center gap-x-6">
<TeamSelector
isLoading={getTeams.isLoading}
teams={getTeams.items}
selectedTeamId={selectedTeamId}
onSelectionChange={setSelectedTeamId}
/>

<CreateTeamModal reloadTeams={getTeams.reload} />
</div>
);
}
22 changes: 22 additions & 0 deletions app/(app)/projects/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 1,22 @@
import { CreateProjectModal } from '@/components/projects/create-project-modal';
import { TeamsProvider } from '@/providers/teams-provider';
import { ProjectList } from './_components/project-list';
import { TeamNavs } from './_components/team-navs';

export default async function ProjectsPage() {
return (
<section id="projects-page" className="my-8 flex-1 flex-col flex">
<TeamsProvider isSelectFirstTeam>
<>
<div className="flex flex-row items-center justify-between px-32">
<TeamNavs />

<CreateProjectModal />
</div>

<ProjectList />
</>
</TeamsProvider>
</section>
);
}
8 changes: 4 additions & 4 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 1,6 @@
import RootProvider from '@/providers/root-provider';
import type { Metadata } from 'next';
import localFont from 'next/font/local';
import { AntdRegistry } from '@ant-design/nextjs-registry';
import './globals.css';

const geistSans = localFont({
Expand All @@ -25,11 25,11 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased font-[family-name:var(--font-geist-sans)]`}
className={`${geistSans.variable} ${geistMono.variable} antialiased font-[family-name:var(--font-geist-sans)] min-h-screen`}
>
<AntdRegistry>{children}</AntdRegistry>
<RootProvider>{children}</RootProvider>
</body>
</html>
);
Expand Down
Loading

0 comments on commit 06208b1

Please sign in to comment.