Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/app/workspaces/new/page.tsx
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 />;
}
22 changes: 22 additions & 0 deletions src/entities/workspace/api/create-workspace.ts
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)}` };
}
48 changes: 48 additions & 0 deletions src/entities/workspace/config/template.ts
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',
];
6 changes: 6 additions & 0 deletions src/entities/workspace/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
2 changes: 2 additions & 0 deletions src/features/create-workspace/index.ts
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';
12 changes: 12 additions & 0 deletions src/features/create-workspace/model/schema.ts
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 src/features/create-workspace/ui/CreateWorkspaceDialog.tsx
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);
}
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</DialogContent>
</Dialog>
);
}
Loading