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
21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,17 @@ npm run dev

## 스크립트

| 명령어 | 설명 |
| ---------------------- | ----------------------------------------- |
| `npm run dev` | 개발 서버 실행 |
| `npm run build` | 프로덕션 빌드 |
| `npm run start` | 프로덕션 서버 실행 |
| `npm run lint` | ESLint 검사 |
| `npm run typecheck` | TypeScript 타입 검사 |
| `npm run format` | Prettier 포맷 적용 |
| `npm run format:check` | Prettier 포맷 검사 |
| `npm run check` | lint + typecheck + format:check 일괄 검사 |
| 명령어 | 설명 |
| ---------------------- | --------------------------------------------------- |
| `npm run dev` | 개발 서버 실행 |
| `npm run build` | 프로덕션 빌드 |
| `npm run start` | 프로덕션 서버 실행 |
| `npm run lint` | ESLint 검사 |
| `npm run lint:tw` | Tailwind spacing 임의값 프리셋 검사 |
| `npm run typecheck` | TypeScript 타입 검사 |
| `npm run format` | Prettier 포맷 적용 |
| `npm run format:check` | Prettier 포맷 검사 |
| `npm run check` | lint + lint:tw + typecheck + format:check 일괄 검사 |

## 프로젝트 구조

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"lint:tw": "node scripts/check-tailwind-spacing.mjs",
"typecheck": "tsc --noEmit",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "npm run lint && npm run typecheck && npm run format:check"
"check": "npm run lint && npm run lint:tw && npm run typecheck && npm run format:check"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
Expand Down
Binary file added public/workspaces/empty-workspace.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
87 changes: 87 additions & 0 deletions scripts/check-tailwind-spacing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/* eslint-disable no-console -- CLI 검사 스크립트로 콘솔 출력이 본 기능이다 */
// Tailwind spacing 임의값 게이트
// gap / p* / m* / space 계열에 `-[Npx]`를 쓰면 0.25 단위 프리셋(N÷4)으로 변환하도록 강제한다.
// 이 규칙은 eslint/prettier로 잡히지 않고 editor의 Tailwind IntelliSense 경고로만 뜨므로,
// CI(`npm run check`)에서 막아 누구든 놓치지 않게 한다.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';

const ROOT = 'src';

// spacing scale를 그대로 쓰는 유틸만 대상으로 한다 (width/height/inset 등 치수는 제외)
const SPACING_PREFIXES = [
'gap-x',
'gap-y',
'gap',
'px',
'py',
'pt',
'pb',
'pl',
'pr',
'p',
'mx',
'my',
'mt',
'mb',
'ml',
'mr',
'm',
'space-x',
'space-y',
];

// 예: gap-[15px], sm:p-[21px], -mt-[8px], group-hover:gap-x-[12px]
const PATTERN = new RegExp(
// variant prefix는 유한 반복({0,10})으로 바운딩해 중첩 quantifier 백트래킹(ReDoS)을 방지한다
'(?:^|[\\s"\'\\x60])(?:[a-z][a-z-]*:){0,10}(-?)(' +
SPACING_PREFIXES.join('|') +
')-\\[(\\d+(?:\\.\\d+)?)px\\]',
'g',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function walk(dir) {
const files = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) files.push(...walk(full));
else if (/\.(ts|tsx|js|jsx)$/.test(entry)) files.push(full);
}
return files;
}

// 주석(//, /* */) 안의 예시 클래스가 오탐되지 않도록 스캔 전 주석을 제거한다 (줄 번호는 유지)
function stripComments(source) {
return source
.replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, ' '))
.replace(/\/\/[^\n]*/g, '');
}

const violations = [];
for (const file of walk(ROOT)) {
const lines = stripComments(readFileSync(file, 'utf8')).split('\n');
lines.forEach((line, index) => {
for (const match of line.matchAll(PATTERN)) {
const [, sign, prefix, px] = match;
violations.push({
file,
line: index + 1,
from: `${sign}${prefix}-[${px}px]`,
to: `${sign}${prefix}-${Number(px) / 4}`,
});
}
});
}

if (violations.length > 0) {
console.error(
'\n✗ Tailwind spacing 임의값이 발견되었습니다. 0.25 단위 프리셋으로 변환하세요 (N÷4):\n',
);
for (const v of violations) {
console.error(` ${v.file}:${v.line} ${v.from} → ${v.to}`);
}
console.error(`\n총 ${violations.length}건. 예) gap-[15px] → gap-3.75, p-[21px] → p-5.25\n`);
process.exit(1);
}

console.log('✓ Tailwind spacing 프리셋 검사 통과');
10 changes: 10 additions & 0 deletions src/app/workspaces/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Metadata } from 'next';
import { WorkspacesPage } from '@/views/workspaces';

export const metadata: Metadata = {
title: '내 워크스페이스 · Syncly',
};

export default function Page() {
return <WorkspacesPage />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export function countSchedulesByWeekday({
config,
weekday,
}: CountSchedulesByWeekdayParams): Record<string, number> {
const counts = Object.fromEntries(config.shifts.map((shift) => [shift.id, 0]));
const counts = Object.fromEntries(config.shifts.map((shift) => [shift.id, 0]));

const uniqueEntries = new Map<string, WorkScheduleEntry>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ export function getWorkMembersByWeekday({
config,
weekday,
}: GetWorkMembersByWeekdayParams): WorkspaceMember[] {
const offShiftIds = new Set(config.shifts.filter((shift) => shift.isOff).map((shift) => shift.id));
const offShiftIds = new Set(
config.shifts.filter((shift) => shift.isOff).map((shift) => shift.id),
);

const workingUserIds = new Set(
schedule
Expand Down
51 changes: 51 additions & 0 deletions src/entities/workspace/api/get-my-workspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// get_my_workspaces RPC의 Mock 구현
// 백엔드 준비 시 supabase.rpc('get_my_workspaces') 호출로 교체한다 (반환 shape 동일)
import type { WorkspaceSummary } from '../model/workspace.types';

// updated_at은 호출 시점 기준 상대값으로 생성해 "최근 활동" 표기가 자연스럽게 유지되도록 한다
type MockWorkspaceSeed = Omit<WorkspaceSummary, 'updated_at' | 'progress'> & {
activityMinutesAgo: number;
};

const MOCK_WORKSPACE_SEEDS: MockWorkspaceSeed[] = [
{
id: 'ws-capstone-design',
name: '캡스톤 디자인 팀',
purpose: 'team-project',
member_count: 5,
task_count: 12,
done_task_count: 7,
activityMinutesAgo: 10,
},
{
id: 'ws-fitto-app',
name: 'Fitto 앱 개발팀',
purpose: 'side-project',
member_count: 4,
task_count: 24,
done_task_count: 18,
activityMinutesAgo: 60,
},
{
id: 'ws-cafe-gray',
name: '카페 그레이 운영',
purpose: 'store-operation',
member_count: 6,
task_count: 8,
done_task_count: 5,
activityMinutesAgo: 180,
},
];

export async function getMyWorkspaces(): Promise<WorkspaceSummary[]> {
const now = Date.now();

return MOCK_WORKSPACE_SEEDS.map(({ activityMinutesAgo, ...summary }) => ({
...summary,
progress:
summary.task_count === 0
? 0
: Math.round((summary.done_task_count / summary.task_count) * 100),
updated_at: new Date(now - activityMinutesAgo * 60_000).toISOString(),
}));
}
35 changes: 35 additions & 0 deletions src/entities/workspace/config/purpose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// purpose별 표시 정보 — ERD에 템플릿 테이블이 없으므로 라벨/아이콘/포인트 컬러는 프론트에서 매핑한다
import { GraduationCap, LayoutGrid, ShoppingBag, Zap } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import type { WorkspacePurpose } from '../model/workspace.types';

interface WorkspacePurposeMeta {
label: string;
icon: LucideIcon;
gradient: string;
}

export const WORKSPACE_PURPOSE_META: Record<WorkspacePurpose, WorkspacePurposeMeta> = {
'team-project': {
label: '팀 프로젝트',
icon: GraduationCap,
gradient: 'linear-gradient(135deg, #8e51ff 0%, #615fff 100%)',
},
'side-project': {
label: '사이드 프로젝트',
icon: Zap,
gradient: 'linear-gradient(135deg, #2b7fff 0%, #00b8db 100%)',
},
'store-operation': {
label: '매장 운영',
icon: ShoppingBag,
gradient: 'linear-gradient(135deg, #fe9a00 0%, #ff6900 100%)',
},
};

// purpose가 매핑에 없을 때(백엔드 연동 후 값 불일치 등) 사용하는 중립 표시 정보
export const FALLBACK_PURPOSE_META: WorkspacePurposeMeta = {
label: '워크스페이스',
icon: LayoutGrid,
gradient: 'linear-gradient(135deg, #7b7fa8 0%, #5b4ee8 100%)',
};
6 changes: 4 additions & 2 deletions src/entities/workspace/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// 목업 워크스페이스 데이터와 타입의 공개 API입니다.
export type { Workspace, WorkspacePurpose } from './model/workspace.types';
// workspace 엔티티 Public API
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 { getMyWorkspaces } from './api/get-my-workspaces';
14 changes: 14 additions & 0 deletions src/entities/workspace/model/workspace.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,17 @@ export interface Workspace {
name: string;
purpose: WorkspacePurpose;
}

// get_my_workspaces RPC 반환 형태 (내 워크스페이스 목록)
// - *_count와 progress는 서버에서 계산되어 내려온다 (progress = done_task_count / task_count * 100, 미저장)
// - updated_at은 workspaces.updated_at으로, 카드의 "최근 활동" 표기에 사용한다
export interface WorkspaceSummary {
id: string;
name: string;
purpose: WorkspacePurpose;
member_count: number;
task_count: number;
done_task_count: number;
progress: number;
updated_at: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ function completeScheduleEntries({
return [...schedule, ...missingEntries];
}

export function useWorkScheduleState({ initialSchedule, members, config }: UseWorkScheduleStateParams) {
export function useWorkScheduleState({
initialSchedule,
members,
config,
}: UseWorkScheduleStateParams) {
const [schedule, setSchedule] = useState(() =>
completeScheduleEntries({
schedule: initialSchedule,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
// 근무 옵션을 추가, 제거, 정렬하고 시간을 설정할 수 있는 인라인 편집기입니다.
import { ChevronDown, ChevronUp } from 'lucide-react';
import type {
WorkScheduleConfig,
WorkShiftColor,
WorkShiftOption,
} from '@/entities/work-schedule';
import type { WorkScheduleConfig, WorkShiftColor, WorkShiftOption } from '@/entities/work-schedule';

const shiftColors: WorkShiftColor[] = ['sky', 'violet', 'amber', 'slate', 'emerald', 'rose'];

Expand Down Expand Up @@ -166,4 +162,4 @@ export function WorkShiftSettingsPanel({
</div>
</section>
);
}
}
14 changes: 14 additions & 0 deletions src/shared/lib/date.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 상대 시간 포맷 — "방금 전 / N분 전 / N시간 전 / N일 전"
export function formatRelativeTime(isoDate: string): string {
const diffMs = Date.now() - new Date(isoDate).getTime();
const minutes = Math.floor(diffMs / 60_000);

if (minutes < 1) return '방금 전';
if (minutes < 60) return `${minutes}분 전`;

const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}시간 전`;

const days = Math.floor(hours / 24);
return `${days}일 전`;
}
2 changes: 2 additions & 0 deletions src/views/workspaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// workspaces 뷰의 Public API
export { default as WorkspacesPage } from './ui/WorkspacesPage';
36 changes: 36 additions & 0 deletions src/views/workspaces/ui/WorkspacesPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// 내 워크스페이스 페이지 — 헤더 + 참여 중인 워크스페이스 목록(빈 상태 포함)을 조립한다
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { Plus_Jakarta_Sans } from 'next/font/google';
import { getMyWorkspaces } from '@/entities/workspace';
import { WorkspaceList } from '@/widgets/workspace-list';

// Figma 지정 폰트 — 한글은 시스템 폰트로 fallback된다
const jakarta = Plus_Jakarta_Sans({
subsets: ['latin'],
weight: ['400', '600', '700', '800'],
});

export default async function WorkspacesPage() {
const workspaces = await getMyWorkspaces();

return (
<div className={`${jakarta.className} bg-brand-surface flex min-h-screen flex-col`}>
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-6 py-6">
<header className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<h1 className="text-brand-ink text-xl leading-7 font-bold">내 워크스페이스</h1>
<p className="text-brand-muted pt-0.5 text-sm leading-5">참여 중인 워크스페이스 목록</p>
</div>
<Link
href="/workspaces/new"
className="bg-brand flex shrink-0 items-center gap-2 rounded-[18px] px-4 py-2 text-sm font-semibold text-white"
>
<Plus className="size-4" aria-hidden />새 워크스페이스
</Link>
</header>
<WorkspaceList workspaces={workspaces} />
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion src/widgets/landing/landing-hero/ui/HeroSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default function HeroSection() {
className="relative z-10 flex flex-col gap-0.75 text-4xl leading-[1.4] font-extrabold tracking-[-1.2px] sm:text-5xl"
>
<span className="text-brand-ink">협업은 더 가볍게</span>
<span className="animate-gradient-x from-brand-start via-brand-end to-brand-start bg-linear-to-r bg-[length:200%_auto] bg-clip-text text-transparent">
<span className="animate-gradient-x from-brand-start via-brand-end to-brand-start bg-linear-to-r bg-size-[200%_auto] bg-clip-text text-transparent">
성과는 더 빠르게
</span>
</motion.h1>
Expand Down
2 changes: 2 additions & 0 deletions src/widgets/workspace-list/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// workspace-list 위젯의 Public API
export { default as WorkspaceList } from './ui/WorkspaceList';
24 changes: 24 additions & 0 deletions src/widgets/workspace-list/ui/EmptyWorkspaces.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Image from 'next/image';

// 참여 중인 워크스페이스가 없을 때의 빈 상태
export default function EmptyWorkspaces() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3.75 py-20">
<Image
src="/workspaces/empty-workspace.png"
alt=""
width={147}
height={88}
className="pointer-events-none opacity-[0.56] select-none"
/>
<h2 className="text-brand-ink text-2xl leading-[1.4] font-bold tracking-[-0.6px]">
아직 워크스페이스가 없어요
</h2>
<p className="text-brand-muted text-center text-lg leading-[1.4] tracking-[-0.45px]">
팀과 함께 사용할 워크스페이스를 만들어
<br />
프로젝트를 효율적으로 관리해보세요.
</p>
</div>
);
}
Loading