https://www.youtube.com/watch?v=Av9C7xlV0fA
06:22:54
1. 워크스페이스 수정 스키마 정의 (src/features/workspaces/schemas.ts)
- updateWorkspaceSchema 생성
- optional(): 필드를 선택적으로 만들어 부분 업데이트 가능
- transform: 빈 문자열을 undefined로 변환하여 이미지 제거 처리
export const updateWorkspaceSchema = z.object({
name: z.string().trim().min(1, "Must be 1 or more characters").optional(),
image : z.union([
z.instanceof(File),
z.string().transform((value) => value === "" ? undefined : value),
])
.optional(),
})
2. 멤버 권한 확인 유틸리티(src/features/members/utils.ts)
- getMember : 워크스페이스를 수정하기 전에 현재 사용자가 해당 워크스페이스의 멤버인지, 그리고 ADMIN 권한이 있는지 확인
- 특정 워크스페이스 + 사용자 ID로 멤버 문서 조회
- 첫 번째 문서 반환 (조건에 맞는 멤버는 1명만 존재)
import { DATABASE_ID, MEMBERS_ID } from "@/config";
import { Query, type Databases } from "node-appwrite";
interface GetMemberProps{
databases: Databases;
workspaceId: string;
userId: string
};
export const getMember = async ({
databases,
workspaceId,
userId
}:GetMemberProps) => {
const members = await databases.listDocuments(
DATABASE_ID,
MEMBERS_ID,
[
Query.equal("workspaceId", workspaceId),
Query.equal("userId", userId)
]
)
return members.documents[0]
}
3. PATCH API 엔드포인트 구현 ( src/features/workspaces/server/route.ts )
- 권한 검증: ADMIN이 아니면 401 에러 반환
- 이미지 처리: 새 파일이면 업로드, 기존 ID면 그대로 사용
- 문서 업데이트: 변경된 필드만 업데이트
.patch(
"/:workspaceId",
sessionMiddleware,
zValidator("form", updateWorkspaceSchema),
async (c) => {
const databases = c.get("databases");
const storage = c.get("storage");
const user = c.get("user");
const {workspaceId} = c.req.param();
const {name, image} = c.req.valid("form");
const member = await getMember({
databases,
workspaceId,
userId:user.$id
})
if(!member || member.role !== MemberRole.ADMIN){
return c.json({ error: "Unauthorized"}, 401)
}
let uploadedImageUrl : string | undefined
if(image instanceof File){
const file = await storage.createFile(
IMAGES_BUCKET_ID,
ID.unique(),
image
);
uploadedImageUrl = file.$id;
}else{
uploadedImageUrl = image
}
const workspace = await databases.updateDocument(
DATABASE_ID,
WORKSPACES_ID,
workspaceId,
// {
// name,
// imageUrl: uploadedImageUrl,
// }
{
...(name && { name }), // name이 있을 때만 포함
...(uploadedImageUrl && { imageUrl: uploadedImageUrl }), // imageUrl이 있을 때만 포함
}
);
return c.json({data:workspace})
}
)
4. 클라이언트 훅 - useUpdateWorkspace( src/features/workspaces/api/use-update-workspace.ts )
- React Query를 사용한 mutation 훅으로, 워크스페이스 수정 요청을 처리
- 쿼리 무효화: 업데이트 후 전체 워크스페이스 목록 + 특정 워크스페이스 캐시 갱신
- 토스트 알림: 성공/실패 메시지 표시
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.workspaces[':workspaceId']['$patch'], 200>
type RequestType = InferRequestType<typeof client.api.workspaces[':workspaceId']['$patch']>
export const useUpdateWorkspace = () => {
const queryClient = useQueryClient()
const mutation = useMutation<
ResponseType,
Error,
RequestType
>({
mutationFn: async({form, param}) => {
const response = await client.api.workspaces[':workspaceId']['$patch']({form, param});
console.log(response);
if(!response.ok){
throw new Error("Failed to update workspace")
}
return response.json()
},
onSuccess: ({data}) => {
toast.success('Workspace updated')
queryClient.invalidateQueries({queryKey : ['workspaces']})
queryClient.invalidateQueries({queryKey : ['workspaces', data.$id]})
},
onError: ()=> {
toast.error("Failed to update workspace")
}
})
return mutation
}
5. 워크스페이스 설정 페이지 ( src/components/navigation.tsx )
- Settings 페이지 라우팅 : Navigation - Settings 메뉴로 사용자가 대시보드에서 설정 페이지로 쉽게 이동할 수 있도록 함
"use client"
import Link from 'next/link';
import {SettingsIcon, UsersIcon} from 'lucide-react';
import {GoCheckCircle, GoCheckCircleFill, GoHome, GoHomeFill} from 'react-icons/go';
import { useWorkspaceId } from '@/features/workspaces/hooks/use-workspace-id';
import { cn } from '@/lib/utils';
import { usePathname } from 'next/navigation';
const routes = [
// ...
{
label: "Settings",
href: "/settings",
icon: SettingsIcon,
activeIcon: SettingsIcon,
},
// ...
]
export const Navigation = () => {
const workspaceId = useWorkspaceId();
const pathname = usePathname();
return(
<div className='flex flex-col'>
{routes.map((item) => {
const fullHref = `/workspaces/${workspaceId}${item.href}`
const isActive = pathname === fullHref;
const Icon = isActive ? item.activeIcon : item.icon
return (
<Link key={item.href} href={fullHref}>
<div className={cn(
"flex items-center gap-2.5 p-2.5 rounded-md font-medium hover:text-primary transition text-neutral-500",
isActive && "bg-white shadow-sm hover:opacity-100 text-primary"
)}>
<Icon className='size-5 text-neutral-500'/>
{item.label}
</div>
</Link>
)
})}
</div>
)
}
5-1. 서버 액션 - getWorkspace ( src/features/workspaces/actions.ts )
- 설정 페이지에서 기존 워크스페이스 데이터를 불러오는 서버 액션
- 기존 listDocuments 대신 getMember 유틸리티 사용으로 코드 간결화
- 멤버가 아니면 null 반환하여 접근 차단
//src/features/workspaces/type.ts
import {Models} from "node-appwrite";
export type Workspace = Models.Document & {
name: string;
imageUrl: string;
inviteCode: string;
userId: string;
}
"use server"
import { cookies } from 'next/headers'
import { Client, Databases, Query, Account } from 'node-appwrite'
import { AUTH_COOKIE } from '@/features/auth/constants'
import { DATABASE_ID, MEMBERS_ID, WORKSPACES_ID } from '@/config'
import { Workspace } from './type'
import { getMember } from '../members/utils'
...
interface GetWorkspaceProps{
workspaceId:string
}
export const getWorkspace = async ({workspaceId}:GetWorkspaceProps) => {
try{
const client = new Client()
.setEndpoint(process.env.NEXT_PUBLIC_APPWRITE_ENDPOINT!)
.setProject(process.env.NEXT_PUBLIC_APPWRITE_PROJECT!)
const session = await cookies().get(AUTH_COOKIE)
if(!session) return null
client.setSession(session.value);
const databases = new Databases(client);
const account = new Account(client);
// 1. 사용자 인증 확인
const user = await account.get();
// 2. 멤버십 확인
const member = await getMember({
databases,
userId: user.$id,
workspaceId
})
if(!member) return null
// 3. 워크스페이스 데이터 조회
const workspace = await databases.getDocument<Workspace>(
DATABASE_ID,
WORKSPACES_ID,
workspaceId
);
return workspace
}catch{
return null
}
}
5-2. Settings 페이지 컴포넌트 ( src/app/(standalone)/workspaces/[workspaceId]/settings/page.tsx )
import { getCurrent } from "@/features/auth/actions";
import { getWorkspace } from "@/features/workspaces/actions";
import { EditWorkspaceForm } from "@/features/workspaces/component/edit-workspace-form";
import { redirect } from "next/navigation";
interface WorkspaceIdSettingsPageProps{
params:{
workspaceId:string
}
}
const WorkspaceIdSettingsPage = async ({params}:WorkspaceIdSettingsPageProps) => {
const user = await getCurrent();
if(!user) redirect('sign-in');
const initialValues = await getWorkspace({workspaceId: params.workspaceId})
if(!initialValues){
redirect(`/workspaces/${params.workspaceId}`)
}
return(
<div className="w-full lg:max-w-xl">
{/* WorkspaceIdSettingsPage : {params.workspaceId} */}
<EditWorkspaceForm initialValues={initialValues}/>
</div>
)
}
export default WorkspaceIdSettingsPage
5_3. Form 컴포넌트 ( src/features/workspaces/component/edit-workspace-form.tsx )
- Appwrite는 이미지를 저장할 때 파일 ID만 반환하기 때문에,
- Next.js Image 컴포넌트에서 사용하려면 전체 URL을 직접 생성해야함
'use client'
import { useRef } from "react";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { updateWorkspaceSchema } 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 { useUpdateWorkspace } from "../api/use-update-workspace";
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";
import { Workspace } from "../type";
interface EditWorkspaceFormProps{
onCancel?: () => void;
initialValues:Workspace;
}
export const EditWorkspaceForm = ({onCancel, initialValues} : EditWorkspaceFormProps) =>{
const router = useRouter();
const {mutate, isPending} = useUpdateWorkspace();
const inputRef = useRef<HTMLInputElement>(null)
const form = useForm<z.infer<typeof updateWorkspaceSchema>>({
resolver: zodResolver(updateWorkspaceSchema),
defaultValues:{
...initialValues,
image: initialValues.imageUrl ?? ""
}
})
const onSubmit = (values: z.infer<typeof updateWorkspaceSchema>) => {
const finalValues = {
...values,
// image: values.image instanceof File ? values.image : undefined,
image: values.image instanceof File
? values.image
: values.image && values.image !== ""
? values.image // 기존 이미지 ID 유지
: "", // 이미지 제거
};
mutate({
form:finalValues,
param: {workspaceId: initialValues.$id}
}, {
onSuccess:({data})=>{
form.reset();
router.push(`/workspaces/${data.$id}`)
}
})
}
const handleImageChange = (e:React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if(file){
form.setValue("image", file)
}
}
return(
...
<Image
alt="Logo"
fill
className="object-cover"
src={
field.value instanceof File
? URL.createObjectURL(field.value) // 새로 선택한 파일
: field.value ?
// Appwrite 이미지 ID → 완전한 URL 변환
`${APPWRITE_ENDPOINT}/storage/buckets/${IMAGES_BUCKET_ID}/files/${field.value}/view?project=${PROJECT_ID}`
: '' // 이미지 없을 때
}
/>
)
}
http://localhost:3000/workspaces/[workspacesId]/settings

6. Back 버튼 조건부 동작 ( src/features/workspaces/components/edit-workspace-form.tsx )
- onCancel prop이 있으면 → 모달 닫기
- onCancel이 없으면 → 워크스페이스 홈으로 이동
<CardHeader className="flex flex-row items-center gap-x-4 p-7 space-y-0">
<Button
size='sm'
variant="secondary"
onClick={onCancel?
onCancel : ()=> router.push(`/workspaces/${initialValues.$id}`)}
>
<ArrowLeftIcon className="size-4 mr-2"/>
Back
</Button>
<CardTitle className="text-xl font-bold">
{initialValues.name}
</CardTitle>
</CardHeader>

7. 이미지 제거 기능 ( Remove Image 버튼 추가 )
src/features/workspaces/components/create-workspace-form.tsx
src/features/workspaces/component/edit-workspace-form.tsx
{field.value ? (
<Button
type="button"
disabled={isPending}
variant="tertiary"
size="xs"
className="w-fit mt-2"
onClick={()=>inputRef.current?.click()}
>
Remove Image
</Button>
):(
<Button
type="button"
disabled={isPending}
variant="tertiary"
size="xs"
className="w-fit mt-2"
onClick={()=>inputRef.current?.click()}
>
Upload Image
</Button>
)}
8. API Route 설정 ( src/app/api/[[...route]]/route.ts )
import {Hono} from 'hono'
import {handle} from 'hono/vercel'
import auth from '@/features/auth/server/route'
import workspaces from '@/features/workspaces/server/route'
const app = new Hono().basePath("/api");
const routes = app
.route("/auth", auth)
.route("/workspaces", workspaces)
export const GET = handle(routes)
export const POST = handle(routes)
export const PATCH = handle(routes) // PATCH 메서드 추가
// 🔥 RPC 타입 - 클라이언트에서 타입 안전하게 API 호출 가능
export type AppType = typeof routes
* 수정하는 부분에서 이름만 바꿔주면 이미지가 undefined가 전송되서 imageUrl이 사라졌다.
* 서버와 클라이언트 측 코드를 수정해주고 해결했다.
'Clone Coding' 카테고리의 다른 글
| Jira-clone - 워크스페이스 삭제(Delete) 기능 구현 (0) | 2025.10.12 |
|---|---|
| Jira-clone - 서버 쿼리 리팩토링 (0) | 2025.10.11 |
| Jira-clone - 독립 실행형 레이아웃과 워크스페이스 생성 페이지 구현 (0) | 2025.10.10 |
| Jira-clone - 개별 작업 공간 ID 페이지 만들기 (0) | 2025.10.02 |
| Jira-clone - 워크스페이스 멤버십 시스템 만들기 (0) | 2025.10.02 |