본문 바로가기
Clone Coding

Jira-clone - Projects 기능 구현

by zzuny-code 2025. 10. 29.
반응형

https://www.youtube.com/watch?v=37v63U7-iG0&t=4s

0 ~ 35:02 

 

 

hany@hany.com -  workspace를 'code with zzuny' 로 만들어줌 :)

 

 

1. Appwrite 데이터베이스 설정

  1. Databases → Create table → 테이블 이름: projects
  2. Create column에서 다음 컬럼들을 추가:

컬럼명타입크기필수 여부

workspaceId string 50 Required ✅
imageUrl string 1,400,000 Optional
name string 256 Required ✅


2. 환경 변수 설정

 

.env.local

NEXT_PUBLIC_APPWRITE_PROJECTS_ID=projects

 

src/config.ts

export const PROJECTS_ID = process.env.NEXT_PUBLIC_APPWRITE_PROJECTS_ID!;

 

3. 백엔드 API 구축

 

서버 라우트 구현 src/features/projects/server/route.ts

import z from "zod";
import { Hono } from "hono";
import { Query } from "node-appwrite";
import { zValidator } from "@hono/zod-validator";

import { getMember } from "@/features/members/utils";
import { sessionMiddleware } from "@/lib/session-middleware";
import { DATABASE_ID, PROJECTS_ID } from "@/config";

const app = new Hono()

    .get(
        "/",
        sessionMiddleware,
        zValidator("query", z.object({workspaceId: z.string()})),
        async (c)=>{
            const user = c.get("user");
            const databases = c.get("databases");

            const {workspaceId} = c.req.valid("query")

            if(!workspaceId){
                return c.json({error: "Missing workspaceId"}, 400)
            }

            // 권한 확인: 해당 workspace의 멤버인지 체크
            const member = await getMember({
                databases,
                workspaceId,
                userId: user.$id
            })

            if(!member){
                return c.json({error: "Unauthorized"}, 401)
            }
    
            // 프로젝트 목록 조회 (최신순 정렬)
            const projects= await databases.listDocuments(
                DATABASE_ID,
                PROJECTS_ID,
                [
                    Query.equal("workspaceId", workspaceId),
                    Query.orderDesc("$createdAt")
                ]
            )

            return c.json({ data : projects })
        }
    )

export default app

 

API 라우트 연결 src/app/api/[[...route]]/route.ts

import projects from '@/features/projects/server/route'

.route("/projects", projects)

 

💡 API 흐름 설명:

  1. 클라이언트가 /api/projects?workspaceId=xxx 요청
  2. sessionMiddleware로 사용자 인증
  3. getMember로 workspace 접근 권한 확인
  4. Appwrite에서 해당 workspace의 프로젝트 목록 조회
  5. 최신 생성 순으로 정렬하여 반환

 

 

4. 프론트엔드 데이터 페칭


React Query 훅 생성 src/features/projects/api/use-get-projects.ts

import {useQuery} from "@tanstack/react-query"

import {client} from "@/lib/rpc"

interface useGetProjectsProps{
    workspaceId: string;
}

export const useGetProjects = ({workspaceId}:useGetProjectsProps) => {
    const query = useQuery({
        queryKey:["projects", workspaceId],
        queryFn: async () => {
            const response = await client.api.projects.$get({
                query: {workspaceId}
            });

            if(!response.ok){
                throw new Error("Failted to fetch projects")
            }

            const {data} = await response.json();

            return data;
        }
    })

    return query
}

React Query 사용 이유:

  • 자동 캐싱으로 불필요한 API 호출 방지
  • queryKey로 workspace별 데이터 분리 관리
  • 로딩/에러 상태 자동 관리

 

 

5. UI 컴포넌트 구현

 

Projects 컴포넌트 (초기 버전) src/components/projects.tsx

"use client"
 
import { RiAddCircleFill } from "react-icons/ri"

export const Projects = () => {
    return (
        <div className="flex flex-col gap-y-2">
            <div className="flex items-center justify-between">
                <p className="text-xs uppercase text-neutral-500">projects</p>
                <RiAddCircleFill onClick={()=>{}} className="size-5 text-neutral-500 cursor-pointer hover:opacity-75 transition"/>
            </div>
        </div>
    )
}

 

Sidebar에 통합 src/components/sidebar.tsx

import DottedSeparator from '@/components/dottedSeparator'
import Image from 'next/image'
import Link from 'next/link'
import { Navigation } from './navigation'
import { WorkspaceSwitcher } from '@/components/workspace-switcher'
import { Projects } from './projects'

export const Sidebar =() => {
    return(
        <aside className='h-full bg-neutral-100 p-4 w-full'>
            <Link href={"/"}>
                <Image src={"/logo.svg"} alt='logo' width={164} height={48}/>
            </Link>
            <DottedSeparator className='my-4'/>
            <WorkspaceSwitcher/>
            <DottedSeparator className='my-4'/>
            <Navigation/>
            <DottedSeparator className='my-4'/>
            <Projects/>
        </aside>
    )
}

 

데이터 연동 (완성 버전) src/components/projects.tsx

"use client"
 
import { useGetProjects } from "@/features/projects/api/use-get-projects";
import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id"
import { cn } from "@/lib/utils";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { RiAddCircleFill } from "react-icons/ri"

export const Projects = () => {
    
    const projectId = null
    const pathname = usePathname()
    const workspaceId = useWorkspaceId();
    const {data} = useGetProjects({
        workspaceId
    })

    return (
        <div className="flex flex-col gap-y-2">
            <div className="flex items-center justify-between">
                <p className="text-xs uppercase text-neutral-500">projects</p>
                <RiAddCircleFill onClick={()=>{}} className="size-5 text-neutral-500 cursor-pointer hover:opacity-75 transition"/>
            </div>
            {data?.documents.map((project)=>{
                const href = `/workspaces/${workspaceId}/projects/${projectId}`
                const isActive = pathname === href

                return(
                    <Link href={href} key={project.$id}>
                        <div
                            className={cn(
                                "flex items-center gap-2.5 p-2.5 rounded-md hover:opacity-75 transition cursor-pointer text-neutral-500",
                                isActive && "bg-white shadow-sm hover:opacity-100 text-primary"
                            )}
                        >
                            <span className="truncate">{project.name}</span>
                        </div>
                    </Link>
                )
            })}
        </div>
    )
}

⚠️ 주의: 아직 프로젝트를 생성하지 않았으므로 아무것도 안 나오는 게 정상

 

 

 

6. 프로젝트 생성 기능

 

유효성 검사 스키마 src/features/projects/schemas.ts

import {z} from "zod"

export const createProjectSchema = z.object({
    name: z.string().trim().min(1, "Required"), 
    image : z.union([
        z.instanceof(File),
        z.string().transform((value) => value === "" ? undefined : value),
    ]).optional(),
    workspaceId: z.string()
})

스키마 설명:

  • name: 필수 입력, 공백 제거 후 최소 1글자
  • image: 파일 업로드 또는 URL 문자열 (선택)
  • workspaceId: 필수

서버 POST 엔드포인트 src/features/projects/server/route.ts

.post(
    "/",
    sessionMiddleware,
    zValidator("form", createProjectSchema),
    async (c) => {
        const databases = c.get("databases")
        const storage = c.get("storage")
        const user = c.get("user")
        
        const {name, image, workspaceId} = c.req.valid("form")

        // 권한 확인
        const member = await getMember({
            databases,
            workspaceId,
            userId: user.$id
        })

        if(!member) {
            return c.json({error:"Unathorized"}, 401)
        }

        let uploadedImageUrl: string | undefined;

        console.log('data', image);

        // 이미지 파일 업로드 처리
        if(image instanceof File){
            
            const file = await storage.createFile(
                IMAGES_BUCKET_ID,
                ID.unique(),
                image
            );

            uploadedImageUrl = file.$id                
        }
        
        // 프로젝트 문서 생성
        const project = await databases.createDocument(
            DATABASE_ID,
            PROJECTS_ID,
            ID.unique(),
            {
                name,
                imageUrl: uploadedImageUrl,
                workspaceId
            }
        );

        return c.json({data: project})
    }

)

 

생성 Mutation 훅 src/features/projects/api/use-create-projects.ts

import { toast } from 'sonner';
import {useMutation, useQueryClient} from '@tanstack/react-query'
import { InferRequestType, InferResponseType } from 'hono';
import {client} from '@/lib/rpc'

type ResponseType = InferResponseType<typeof client.api.projects['$post']>
type RequestType = InferRequestType<typeof client.api.projects['$post']>

export const useCreateProject = () => {

    const queryClient = useQueryClient()

    const mutation = useMutation<
        ResponseType,
        Error,
        RequestType
    >({
        mutationFn: async({form}) => {
            const response = await client.api.projects["$post"]({form});         

            if(!response.ok){
                throw new Error("Failed to create projects")
            }

            return response.json()
        },
        onSuccess: () => {
            toast.success('Projects created')
            // 프로젝트 목록 캐시 무효화 → 자동 리페치
            queryClient.invalidateQueries({queryKey : ['projects']})
        },
        onError: ()=> {
            toast.error("Failed to create projects")
        }
    })
    return mutation
}

 

모달 상태 관리 src/features/projects/hooks/use-create-project-modal.ts

import {useQueryState, parseAsBoolean} from "nuqs"

export const useCreateProjectModal = () => {

    const [isOpen, setIsOpen] = useQueryState(
        "create-project",
        parseAsBoolean.withDefault(false).withOptions({clearOnDefault:true})
    )

    const open = () => setIsOpen(true)
    const close = () => setIsOpen(false)

    return{
        isOpen,
        open,
        close,
        setIsOpen,
    }

}

nuqs 사용 이유:

  • URL 쿼리 파라미터로 모달 상태 관리
  • 새로고침해도 모달 상태 유지
  • 뒤로가기/앞으로가기 지원

 

프로젝트 생성 폼 src/features/projects/components/create-project-form.tsx

'use client'

import { useRef } from "react";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";


import { useWorkspaceId } from "@/features/workspaces/hooks/use-workspace-id";
import { createProjectSchema } from "../schemas";
import { useForm } from "react-hook-form";
import z from "zod";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useCreateProject } from "../api/use-create-projects";
import DottedSeparator from "@/components/dottedSeparator";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import Image from "next/image";
import { ImageIcon } from "lucide-react";
import { cn } from "@/lib/utils";

interface CreateProjectFormProps{
    onCancel?: () => void;
}

export const CreateProjectForm = ({onCancel} : CreateProjectFormProps) =>{

    const workspaceId = useWorkspaceId()
    const router = useRouter();
    const {mutate, isPending} = useCreateProject();

    const inputRef = useRef<HTMLInputElement>(null)

    const form = useForm<z.infer<typeof createProjectSchema>>({
        resolver: zodResolver(createProjectSchema.omit({workspaceId: true})),
        defaultValues:{
            name: ''
        }
    })

    const onSubmit = (values: z.infer<typeof createProjectSchema>) => {
        
        const finalValues = {
            ...values,
            workspaceId,
            image: values.image instanceof File ? values.image : ""
        }
        
        mutate({form:finalValues}, {
            onSuccess:()=>{
                form.reset();
            }
        })
    }

    const handleImageChange = (e:React.ChangeEvent<HTMLInputElement>) => {
        const file = e.target.files?.[0];
        if(file){
            form.setValue("image", file)
        } 
    }


    return(
        <Card className="w-full h-full border-none shadow-none">
            <CardHeader className="flex p-7">
                <CardTitle className="text-xl font-bold">
                    Create a new Project
                </CardTitle>
            </CardHeader>
            <div className="px=7">
                <DottedSeparator/>
            </div>
            <CardContent className="p-7">
                <Form {...form}>
                    <form onSubmit={form.handleSubmit(onSubmit)}>
                        <div className="flex flex-col gap-y-4">
                            {/* 프로젝트 이름 입력 */}
                            <FormField
                                ...
                            />
                            {/* 이미지 업로드 */}
                            <FormField
                                ...
                            />
                        </div>
                        <DottedSeparator className="py-7"/>
                        
                        {/* 버튼 그룹 */}
                        <div className="flex items-center justify-between">
                            <Button
                                ...
                            >
                                Cancel
                            </Button>
                            <Button
                                ...
                            >
                                Create Project
                            </Button>
                        </div>
                    </form>
                </Form>
            </CardContent>
        </Card>
    )
}

 

모달 컴포넌트 src/features/projects/components/create-project-modal.tsx

'use client'

import { ResponsiveModal } from "@/components/responsive-modal"
import { CreateProjectForm } from "./create-project-form"
import { useCreateProjectModal } from "../hooks/use-create-project-modal"

export const CreateProjectModal = () => {

    const {isOpen, setIsOpen, close} = useCreateProjectModal();

    return(
        <ResponsiveModal open={isOpen} onOpenChange={setIsOpen}>
            <CreateProjectForm onCancel={close}/>
        </ResponsiveModal>
    )
}

 

 

레이아웃에 모달 추가 src/app/(dashboard)/layout.tsx

import { CreateProjectModal } from "@/features/projects/components/create-project-modal";

// ... 기존 코드 ...

export default function DashboardLayout({children}: {children: React.ReactNode}) {
    return (
        <>
            <CreateProjectModal/>
            {/* ... 기존 레이아웃 코드 ... */}
        </>
    );
}

 

프로젝트 목록에 모달 연결 src/components/projects.tsx

import { useCreateProjectModal } from "@/features/projects/hooks/use-create-project-modal";

export const Projects = () => {
    const {open} = useCreateProjectModal();
    
    // ... 기존 코드 ...
    
    return (
        <div className="flex flex-col gap-y-2">
            <div className="flex items-center justify-between">
                <p className="text-xs uppercase text-neutral-500">projects</p>
                <RiAddCircleFill 
                    onClick={open}  // 모달 열기
                    className="size-5 text-neutral-500 cursor-pointer hover:opacity-75 transition"
                />
            </div>
            {/* ... 프로젝트 목록 ... */}
        </div>
    );
}

 

 

 

 

7. 프로젝트 아바타 구현

 

ProjectAvatar 컴포넌트 src/features/projects/components/project-avatar.tsx

import Image from "next/image"
import {cn} from "@/lib/utils"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"

import {
  APPWRITE_ENDPOINT, // 너의 appwrite 엔드포인트 (ex: fra.cloud.appwrite.io)
  PROJECT_ID,         // 너의 프로젝트 ID
  IMAGES_BUCKET_ID    // images Storage 버킷 ID
} from "@/config" 


interface ProjectAvatarProps {
    name: string,
    image?: string,
    className?:string
    fallbackClassName?: string;
}

export const ProjectAvatar = ({name, image, className, fallbackClassName}:ProjectAvatarProps) => {
    

    let imageUrl = ''; 

    if(image && typeof image === 'string') {

        // baseEndpoint에서 '/v1' 경로 추가
        const baseEndpoint = APPWRITE_ENDPOINT.startsWith('https://') 
            ? APPWRITE_ENDPOINT 
            : `https://${APPWRITE_ENDPOINT}`;

        // '/v1'이 이미 포함되어 있는지 확인
        const endpoint = baseEndpoint.includes('/v1') 
            ? baseEndpoint 
            : `${baseEndpoint}/v1`;

        imageUrl = `${endpoint}/storage/buckets/${IMAGES_BUCKET_ID}/files/${image}/view?project=${PROJECT_ID}`;

        // https://fra.cloud.appwrite.io/v1/storage/buckets/68d8d717003253abebae/files/68da5074000291bf93ab/view?project=68ca4e5d002612399c40&mode=admin
        // 만약 'mode=admin' 이 꼭 필요하다면 &mode=admin 을 붙여도 되지만 보통은 필요 없음.

        return(
            <div className={cn(
                "size-5 relative rounded-md overflow-hidden",
                className
            )}>
                <Image src={imageUrl} alt={name} fill className="object-cover"/>
            </div>
        )
    }

    
    return(
        <Avatar className={cn("size-5 rounded-md", className)}>
            <AvatarFallback className={cn(
                "text-white bg-blue-600 font-semibold text-sm uppercase rounded-md",
                fallbackClassName,
                )}>
                {name[0]}
            </AvatarFallback>
        </Avatar>
    )
}

💡 Appwrite 이미지 URL 구조:

https://[endpoint]/v1/storage/buckets/[bucket-id]/files/[file-id]/view?project=[project-id]

 

프로젝트 목록에 아바타 추가 src/components/projects.tsx

...
 <ProjectAvatar image={project.imageUrl} name={project.name}/>
 <span className="truncate">{project.name}</span>

 

 

새로운 workspace를 만들면 기존에 만들었던 projects 는 사라짐. 

 

폴더 구조

src/
├── app/
│   ├── api/
│   │   └── [[...route]]/
│   │       └── route.ts    # API 라우트 통합
│   └── (dashboard)/
│       └── layout.tsx      # CreateProjectModal 추가
│
├── components/
│   ├── projects.tsx        # 프로젝트 목록 UI
│   └── sidebar.tsx         # Projects 컴포넌트 추가
│
├── features/
│   └── projects/
│       ├── api/
│       │   ├── use-get-projects.ts          # 조회 훅
│       │   └── use-create-projects.ts       # 생성 훅
│       ├── components/
│       │   ├── project-avatar.tsx           # 프로젝트 아바타
│       │   ├── create-project-form.tsx      # 생성 폼
│       │   └── create-project-modal.tsx     # 모달 래퍼
│       ├── hooks/
│       │   └── use-create-project-modal.ts  # 모달 상태
│       ├── server/
│       │   └── route.ts                     # Hono 서버 라우트
│       └── schemas.ts                       # Zod 스키마
│
├── config.ts                                # 환경 변수
└── .env.local                               # 환경 변수 파일
반응형