https://www.youtube.com/watch?v=37v63U7-iG0&t=4s
0 ~ 35:02
hany@hany.com - workspace를 'code with zzuny' 로 만들어줌 :)

1. Appwrite 데이터베이스 설정
- Databases → Create table → 테이블 이름: projects
- 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 흐름 설명:
- 클라이언트가 /api/projects?workspaceId=xxx 요청
- sessionMiddleware로 사용자 인증
- getMember로 workspace 접근 권한 확인
- Appwrite에서 해당 workspace의 프로젝트 목록 조회
- 최신 생성 순으로 정렬하여 반환
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 # 환경 변수 파일'Clone Coding' 카테고리의 다른 글
| Jira-clone - Tasks 기능 구현 (0) | 2025.11.05 |
|---|---|
| Jira-clone - Project ID 페이지 구현 및 에러/로딩 처리 정리 (0) | 2025.11.05 |
| Jira-clone - 멤버 api 빌드 (0) | 2025.10.17 |
| Jira-clone - 워크스페이스 초대 링크 기능 (0) | 2025.10.12 |
| Jira-clone - 워크스페이스 초대 링크 기능 구현 (0) | 2025.10.12 |