-
Notifications
You must be signed in to change notification settings - Fork 3
feat: 워크스페이스 생성 페이지 구현 (#13) #20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import type { Metadata } from 'next'; | ||
| import { CreateWorkspacePage } from '@/views/create-workspace'; | ||
|
|
||
| export const metadata: Metadata = { | ||
| title: '워크스페이스 만들기 · Syncly', | ||
| }; | ||
|
|
||
| export default function Page() { | ||
| return <CreateWorkspacePage />; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| // create_workspace RPC의 Mock 구현 | ||
| // 백엔드 준비 시 supabase.rpc('create_workspace')로 교체한다 | ||
| // (실제로는 workspaces insert → invite_code 생성 → owner 등록 → purpose 기준 workspace_modules 생성 후 id 반환) | ||
| import type { WorkspacePurpose } from '../model/workspace.types'; | ||
|
|
||
| export interface CreateWorkspaceInput { | ||
| name: string; | ||
| description?: string; | ||
| purpose: WorkspacePurpose; | ||
| } | ||
|
|
||
| export async function createWorkspace(input: CreateWorkspaceInput): Promise<{ id: string }> { | ||
| // Mock: 실제 저장 없이 워크스페이스 id만 생성해 반환한다 | ||
| const slug = | ||
| input.name | ||
| .trim() | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9가-힣]+/g, '-') | ||
| .replace(/^-+|-+$/g, '') || 'workspace'; | ||
|
|
||
| return { id: `ws-${slug}-${Date.now().toString(36)}` }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| // 워크스페이스 생성 시 보여줄 템플릿(purpose) 상세 정보 — 라벨/아이콘/gradient는 WORKSPACE_PURPOSE_META 참고 | ||
| import type { WorkspacePurpose } from '../model/workspace.types'; | ||
|
|
||
| export interface WorkspaceTemplateDetail { | ||
| subtitle: string; // 타깃 사용자 (예: 대학교 팀플, 스터디) | ||
| description: string; // 템플릿 설명 | ||
| tags: string[]; // 포함되는 기능 | ||
| tagBg: string; | ||
| tagText: string; | ||
| borderColor: string; | ||
| } | ||
|
|
||
| export const WORKSPACE_TEMPLATE_DETAIL: Record<WorkspacePurpose, WorkspaceTemplateDetail> = { | ||
| 'team-project': { | ||
| subtitle: '대학교 팀플, 스터디', | ||
| description: | ||
| '수업 팀플이나 스터디 그룹에 딱 맞는 구성입니다. 역할 분담부터 일정 관리까지 한 곳에서.', | ||
| tags: ['업무 분담 보드', '회의록', '자료실', '캘린더', '그룹 채팅'], | ||
| tagBg: '#ede9fe', | ||
| tagText: '#7008e7', | ||
| borderColor: '#ede9fe', | ||
| }, | ||
| 'side-project': { | ||
| subtitle: '개발팀, 스타트업 소규모 팀', | ||
| description: | ||
| '빠르게 실행하는 소규모 개발팀을 위한 구성입니다. 칸반과 스프린트로 속도 있게 진행하세요.', | ||
| tags: ['칸반 보드', '스프린트 관리', '회의록', '채팅', '진행률 차트'], | ||
| tagBg: '#dbeafe', | ||
| tagText: '#1447e6', | ||
| borderColor: '#dbeafe', | ||
| }, | ||
| 'store-operation': { | ||
| subtitle: '카페, 음식점, 소규모 가게', | ||
| description: | ||
| '매장 직원들과 공지, 스케줄, 업무를 쉽게 공유하세요. 복잡한 기능 없이 꼭 필요한 것만.', | ||
| tags: ['공지 게시판', '업무 스케줄', '자료실', '채팅', '캘린더'], | ||
| tagBg: '#fef3c6', | ||
| tagText: '#bb4d00', | ||
| borderColor: '#ffe888', | ||
| }, | ||
| }; | ||
|
|
||
| // 선택 화면 카드 렌더 순서 | ||
| export const WORKSPACE_TEMPLATE_ORDER: WorkspacePurpose[] = [ | ||
| 'team-project', | ||
| 'side-project', | ||
| 'store-operation', | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| // create-workspace 기능의 Public API | ||
| export { default as CreateWorkspaceDialog } from './ui/CreateWorkspaceDialog'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { z } from 'zod'; | ||
|
|
||
| export const createWorkspaceSchema = z.object({ | ||
| name: z | ||
| .string() | ||
| .trim() | ||
| .min(1, '워크스페이스 이름을 입력해주세요') | ||
| .max(50, '이름은 50자 이내로 입력해주세요'), | ||
| description: z.string().trim().max(200, '설명은 200자 이내로 입력해주세요').optional(), | ||
| }); | ||
|
|
||
| export type CreateWorkspaceForm = z.infer<typeof createWorkspaceSchema>; |
162 changes: 162 additions & 0 deletions
162
src/features/create-workspace/ui/CreateWorkspaceDialog.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| 'use client'; | ||
|
|
||
| // 워크스페이스 생성 Dialog — 선택된 템플릿 요약 + 이름/설명 입력 후 Mock 생성 | ||
| import { useRouter } from 'next/navigation'; | ||
| import { useState } from 'react'; | ||
| import { useForm } from 'react-hook-form'; | ||
| import { zodResolver } from '@hookform/resolvers/zod'; | ||
| import { | ||
| WORKSPACE_PURPOSE_META, | ||
| WORKSPACE_TEMPLATE_DETAIL, | ||
| createWorkspace, | ||
| type WorkspacePurpose, | ||
| } from '@/entities/workspace'; | ||
| import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/shared/ui/dialog'; | ||
| import { Input } from '@/shared/ui/input'; | ||
| import { Label } from '@/shared/ui/label'; | ||
| import { Textarea } from '@/shared/ui/textarea'; | ||
| import { createWorkspaceSchema, type CreateWorkspaceForm } from '../model/schema'; | ||
|
|
||
| interface CreateWorkspaceDialogProps { | ||
| purpose: WorkspacePurpose | null; | ||
| open: boolean; | ||
| onOpenChange: (open: boolean) => void; | ||
| } | ||
|
|
||
| const INPUT_CLASS = | ||
| 'bg-brand-secondary text-brand-ink placeholder:text-brand-ink/50 h-11 w-full rounded-[18px] border-2 border-transparent px-4.5 text-sm transition-colors focus-visible:border-brand focus-visible:ring-0'; | ||
|
|
||
| export default function CreateWorkspaceDialog({ | ||
| purpose, | ||
| open, | ||
| onOpenChange, | ||
| }: CreateWorkspaceDialogProps) { | ||
| const router = useRouter(); | ||
| const [isSubmitting, setIsSubmitting] = useState(false); | ||
| const { | ||
| register, | ||
| handleSubmit, | ||
| reset, | ||
| setFocus, | ||
| formState: { errors }, | ||
| } = useForm<CreateWorkspaceForm>({ | ||
| resolver: zodResolver(createWorkspaceSchema), | ||
| defaultValues: { name: '', description: '' }, | ||
| }); | ||
|
|
||
| const handleOpenChange = (next: boolean) => { | ||
| if (!next) reset(); | ||
| onOpenChange(next); | ||
| }; | ||
|
|
||
| const onSubmit = async (values: CreateWorkspaceForm) => { | ||
| if (!purpose) return; | ||
| setIsSubmitting(true); | ||
| try { | ||
| await createWorkspace({ name: values.name, description: values.description, purpose }); | ||
| router.push('/workspaces'); | ||
| // 성공 시 페이지 이동으로 언마운트되므로 isSubmitting을 리셋하지 않는다(버튼 깜빡임 방지) | ||
| } catch (error) { | ||
| // TODO: 실패 알림 UI(toast 등) 추가 — 현재는 Mock이라 실패하지 않음 | ||
| console.error(error); | ||
| setIsSubmitting(false); | ||
| } | ||
| }; | ||
|
|
||
| const meta = purpose ? WORKSPACE_PURPOSE_META[purpose] : null; | ||
| const detail = purpose ? WORKSPACE_TEMPLATE_DETAIL[purpose] : null; | ||
| const PurposeIcon = meta?.icon; | ||
|
|
||
| return ( | ||
| <Dialog open={open} onOpenChange={handleOpenChange}> | ||
| <DialogContent | ||
| onOpenAutoFocus={(event) => { | ||
| // 기본 포커스(닫기 버튼) 대신 이름 입력으로 포커스를 보낸다 | ||
| event.preventDefault(); | ||
| setFocus('name'); | ||
| }} | ||
| className="border-brand/10 gap-0 rounded-[16px] bg-white p-8.25 sm:max-w-[512px]" | ||
| > | ||
| {meta && detail && PurposeIcon ? ( | ||
| <> | ||
| <div className="flex items-center gap-3"> | ||
| <div | ||
| className="flex size-10 shrink-0 items-center justify-center rounded-[18px]" | ||
| style={{ backgroundImage: meta.gradient }} | ||
| > | ||
| <PurposeIcon className="size-5 text-white" aria-hidden /> | ||
| </div> | ||
| <div className="flex min-w-0 flex-col"> | ||
| <DialogTitle className="text-brand-ink text-xl leading-[30px] font-bold"> | ||
| {meta.label} | ||
| </DialogTitle> | ||
| <DialogDescription className="text-brand-muted text-xs leading-4"> | ||
| 선택된 템플릿 | ||
| </DialogDescription> | ||
| </div> | ||
| </div> | ||
|
|
||
| <div className="bg-brand-secondary mt-6 rounded-[18px] p-3"> | ||
| <p className="text-brand-muted text-xs leading-4 font-semibold">포함되는 기능</p> | ||
| <div className="mt-2 flex flex-wrap gap-2"> | ||
| {detail.tags.map((tag) => ( | ||
| <span | ||
| key={tag} | ||
| className="rounded-full px-2 py-0.5 text-[13px] leading-4 font-semibold" | ||
| style={{ backgroundColor: detail.tagBg, color: detail.tagText }} | ||
| > | ||
| {tag} | ||
| </span> | ||
| ))} | ||
| </div> | ||
| </div> | ||
|
|
||
| <form onSubmit={handleSubmit(onSubmit)} className="mt-5 flex flex-col gap-4"> | ||
| <div className="flex flex-col gap-1.5"> | ||
| <Label htmlFor="workspace-name" className="text-brand-ink text-sm font-semibold"> | ||
| 워크스페이스 이름 <span className="text-brand">*</span> | ||
| </Label> | ||
| <Input | ||
| id="workspace-name" | ||
| placeholder="예: 캡스톤 디자인 팀" | ||
| autoComplete="off" | ||
| className={INPUT_CLASS} | ||
| aria-invalid={Boolean(errors.name)} | ||
| {...register('name')} | ||
| /> | ||
| {errors.name && <p className="text-xs text-red-500">{errors.name.message}</p>} | ||
| </div> | ||
|
|
||
| <div className="flex flex-col gap-1.5"> | ||
| <Label | ||
| htmlFor="workspace-description" | ||
| className="text-brand-ink text-sm font-semibold" | ||
| > | ||
| 설명 (선택) | ||
| </Label> | ||
| <Textarea | ||
| id="workspace-description" | ||
| placeholder="워크스페이스에 대해 간단히 설명해주세요" | ||
| className={`${INPUT_CLASS} h-[84px] resize-none py-3`} | ||
| aria-invalid={Boolean(errors.description)} | ||
| {...register('description')} | ||
| /> | ||
| {errors.description && ( | ||
| <p className="text-xs text-red-500">{errors.description.message}</p> | ||
| )} | ||
| </div> | ||
|
|
||
| <button | ||
| type="submit" | ||
| disabled={isSubmitting} | ||
| className="bg-brand mt-2 rounded-[18px] py-3 text-base font-semibold text-white transition-opacity disabled:opacity-60" | ||
| > | ||
| {isSubmitting ? '만드는 중...' : '워크스페이스 만들기'} | ||
| </button> | ||
| </form> | ||
| </> | ||
| ) : null} | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| </DialogContent> | ||
| </Dialog> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.