diff --git a/src/app/workspaces/new/page.tsx b/src/app/workspaces/new/page.tsx new file mode 100644 index 0000000..67c9092 --- /dev/null +++ b/src/app/workspaces/new/page.tsx @@ -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 ; +} diff --git a/src/entities/workspace/api/create-workspace.ts b/src/entities/workspace/api/create-workspace.ts new file mode 100644 index 0000000..4a62307 --- /dev/null +++ b/src/entities/workspace/api/create-workspace.ts @@ -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)}` }; +} diff --git a/src/entities/workspace/config/template.ts b/src/entities/workspace/config/template.ts new file mode 100644 index 0000000..4e4eff7 --- /dev/null +++ b/src/entities/workspace/config/template.ts @@ -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 = { + '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', +]; diff --git a/src/entities/workspace/index.ts b/src/entities/workspace/index.ts index 659fd91..dcb08b6 100644 --- a/src/entities/workspace/index.ts +++ b/src/entities/workspace/index.ts @@ -2,4 +2,10 @@ export type { Workspace, WorkspacePurpose, WorkspaceSummary } from './model/workspace.types'; export { mockWorkspace } from './model/mock-workspace'; export { WORKSPACE_PURPOSE_META, FALLBACK_PURPOSE_META } from './config/purpose'; +export { + WORKSPACE_TEMPLATE_DETAIL, + WORKSPACE_TEMPLATE_ORDER, + type WorkspaceTemplateDetail, +} from './config/template'; export { getMyWorkspaces } from './api/get-my-workspaces'; +export { createWorkspace, type CreateWorkspaceInput } from './api/create-workspace'; diff --git a/src/features/create-workspace/index.ts b/src/features/create-workspace/index.ts new file mode 100644 index 0000000..2171c78 --- /dev/null +++ b/src/features/create-workspace/index.ts @@ -0,0 +1,2 @@ +// create-workspace 기능의 Public API +export { default as CreateWorkspaceDialog } from './ui/CreateWorkspaceDialog'; diff --git a/src/features/create-workspace/model/schema.ts b/src/features/create-workspace/model/schema.ts new file mode 100644 index 0000000..0366cc3 --- /dev/null +++ b/src/features/create-workspace/model/schema.ts @@ -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; diff --git a/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx new file mode 100644 index 0000000..038f20f --- /dev/null +++ b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx @@ -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({ + 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 ( + + { + // 기본 포커스(닫기 버튼) 대신 이름 입력으로 포커스를 보낸다 + event.preventDefault(); + setFocus('name'); + }} + className="border-brand/10 gap-0 rounded-[16px] bg-white p-8.25 sm:max-w-[512px]" + > + {meta && detail && PurposeIcon ? ( + <> + + + + + + + {meta.label} + + + 선택된 템플릿 + + + + + + 포함되는 기능 + + {detail.tags.map((tag) => ( + + {tag} + + ))} + + + + + + + 워크스페이스 이름 * + + + {errors.name && {errors.name.message}} + + + + + 설명 (선택) + + + {errors.description && ( + {errors.description.message} + )} + + + + {isSubmitting ? '만드는 중...' : '워크스페이스 만들기'} + + + > + ) : null} + + + ); +} diff --git a/src/shared/ui/dialog.tsx b/src/shared/ui/dialog.tsx new file mode 100644 index 0000000..89fdda6 --- /dev/null +++ b/src/shared/ui/dialog.tsx @@ -0,0 +1,145 @@ +'use client'; + +import * as React from 'react'; +import { Dialog as DialogPrimitive } from 'radix-ui'; + +import { cn } from '@/shared/lib/utils'; +import { Button } from '@/shared/ui/button'; +import { XIcon } from 'lucide-react'; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function DialogClose({ ...props }: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + Close + + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( + + ); +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<'div'> & { + showCloseButton?: boolean; +}) { + return ( + + {children} + {showCloseButton && ( + + Close + + )} + + ); +} + +function DialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/shared/ui/input.tsx b/src/shared/ui/input.tsx new file mode 100644 index 0000000..4bfaadd --- /dev/null +++ b/src/shared/ui/input.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; + +import { cn } from '@/shared/lib/utils'; + +function Input({ className, type, ...props }: React.ComponentProps<'input'>) { + return ( + + ); +} + +export { Input }; diff --git a/src/shared/ui/label.tsx b/src/shared/ui/label.tsx new file mode 100644 index 0000000..9a4e3ba --- /dev/null +++ b/src/shared/ui/label.tsx @@ -0,0 +1,21 @@ +'use client'; + +import * as React from 'react'; +import { Label as LabelPrimitive } from 'radix-ui'; + +import { cn } from '@/shared/lib/utils'; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/src/shared/ui/textarea.tsx b/src/shared/ui/textarea.tsx new file mode 100644 index 0000000..274140e --- /dev/null +++ b/src/shared/ui/textarea.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; + +import { cn } from '@/shared/lib/utils'; + +function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) { + return ( + + ); +} + +export { Textarea }; diff --git a/src/views/create-workspace/index.ts b/src/views/create-workspace/index.ts new file mode 100644 index 0000000..6de07cd --- /dev/null +++ b/src/views/create-workspace/index.ts @@ -0,0 +1,2 @@ +// create-workspace 뷰의 Public API +export { default as CreateWorkspacePage } from './ui/CreateWorkspacePage'; diff --git a/src/views/create-workspace/ui/CreateWorkspacePage.tsx b/src/views/create-workspace/ui/CreateWorkspacePage.tsx new file mode 100644 index 0000000..27a6323 --- /dev/null +++ b/src/views/create-workspace/ui/CreateWorkspacePage.tsx @@ -0,0 +1,60 @@ +'use client'; + +// 워크스페이스 생성 페이지 — 템플릿 선택 후 생성 Dialog로 이어지는 플로우 +import Link from 'next/link'; +import { useState } from 'react'; +import { Plus_Jakarta_Sans } from 'next/font/google'; +import { WORKSPACE_TEMPLATE_ORDER, type WorkspacePurpose } from '@/entities/workspace'; +import { CreateWorkspaceDialog } from '@/features/create-workspace'; +import TemplateSelectCard from './TemplateSelectCard'; + +const jakarta = Plus_Jakarta_Sans({ + subsets: ['latin'], + weight: ['400', '600', '700', '800'], +}); + +export default function CreateWorkspacePage() { + const [selectedPurpose, setSelectedPurpose] = useState(null); + const [isDialogOpen, setIsDialogOpen] = useState(false); + + const handleSelect = (purpose: WorkspacePurpose) => { + setSelectedPurpose(purpose); + setIsDialogOpen(true); + }; + + return ( + + + + + + 어떤 용도로 쓰실 건가요? + + + 목적에 맞는 템플릿을 선택하면 워크스페이스를 이용할 수 있어요 + + + + {WORKSPACE_TEMPLATE_ORDER.map((purpose) => ( + + ))} + + + + ← 워크스페이스 목록으로 + + + + + + ); +} diff --git a/src/views/create-workspace/ui/TemplateSelectCard.tsx b/src/views/create-workspace/ui/TemplateSelectCard.tsx new file mode 100644 index 0000000..23c0096 --- /dev/null +++ b/src/views/create-workspace/ui/TemplateSelectCard.tsx @@ -0,0 +1,51 @@ +// 템플릿 선택 카드 — 클릭 시 해당 purpose로 생성 Dialog를 연다 +import { + WORKSPACE_PURPOSE_META, + WORKSPACE_TEMPLATE_DETAIL, + type WorkspacePurpose, +} from '@/entities/workspace'; + +interface TemplateSelectCardProps { + purpose: WorkspacePurpose; + onSelect: (purpose: WorkspacePurpose) => void; +} + +export default function TemplateSelectCard({ purpose, onSelect }: TemplateSelectCardProps) { + const meta = WORKSPACE_PURPOSE_META[purpose]; + const detail = WORKSPACE_TEMPLATE_DETAIL[purpose]; + const PurposeIcon = meta.icon; + + return ( + onSelect(purpose)} + style={{ borderColor: detail.borderColor }} + className="flex w-full flex-col gap-3 rounded-[16px] border-2 bg-white p-6.5 text-left transition-shadow hover:shadow-md" + > + + + + + + {meta.label} + {detail.subtitle} + + + {detail.description} + + {detail.tags.map((tag) => ( + + {tag} + + ))} + + + ); +}
포함되는 기능
{errors.name.message}
{errors.description.message}
+ 목적에 맞는 템플릿을 선택하면 워크스페이스를 이용할 수 있어요 +
{detail.subtitle}
{detail.description}