diff --git a/1.png b/1.png new file mode 100644 index 0000000..67fec46 Binary files /dev/null and b/1.png differ diff --git a/docs/26-03-13-board-poll-feature.md b/docs/26-03-13-board-poll-feature.md new file mode 100644 index 0000000..4224a18 --- /dev/null +++ b/docs/26-03-13-board-poll-feature.md @@ -0,0 +1,385 @@ +# 게시판 투표 기능 + +## 개요 + +카카오톡 스타일의 투표 기능으로 게시판 참여도를 높이고 멤버들의 의견을 쉽게 수렴할 수 있습니다. + +## 기능 + +### 투표 유형 + +| 유형 | 설명 | 투표자 공개 | +|------|------|-----------| +| **단일 선택** | 하나의 선택지만 선택 가능 | ✅ 공개 | +| **복수 선택** | 여러 선택지 선택 가능 | ✅ 공개 | +| **날짜 투표** | 일정 조율용 날짜 선택 | ✅ 공개 | +| **익명 투표** | 누가 투표했는지 비공개 | ❌ 비공개 | + +### 투표 생성 + +- **위치**: 게시글 작성 페이지 +- **버튼**: "투표 추가" 클릭 +- **필수 항목**: + - 질문 (투표 제목) + - 투표 유형 + - 선택지 (최소 2개) + - 마감시간 (1시간/6시간/1일/3일/1주) + +### 투표 참여 + +- **조건**: 로그인 필요 +- **제한**: 마감시간 경과 시 참여 불가 +- **변경**: 단일/복수 투표는 재투표 가능 (기존 투표 대체) +- **익명**: 익명 투표는 투표자 정보 비공개 + +### 투표 관리 + +- **위치**: 게시글 수정 페이지 > "투표 관리" 버튼 +- **권한**: 게시글 작성자 또는 관리자 +- **제한**: 투표 참여자가 0명일 때만 수정/삭제 가능 +- **수정 가능 항목**: + - 질문 + - 투표 유형 + - 선택지 (추가/삭제/편집, 최소 2개 유지) + - 마감시간 연장 + +### 투표 현황 + +- **표시 항목**: + - 질문과 투표 유형 뱃지 + - 마감시간 (남은 시간 또는 "마감됨") + - 총 참여자 수 + - 선택지별 투표 현황 (표수, 백분율, 진행바) + - 투표자 목록 (익명 투표 제외) + +- **게시판 목록**: + - 투표가 있는 게시글에 📊 아이콘 표시 + - 투표 개수 표시 (인디고색) + +## 화면 + +### 투표 생성 화면 + +``` +┌─────────────────────────────────────┐ +│ 📊 투표 추가 │ +├─────────────────────────────────────┤ +│ 질문: │ +│ ┌───────────────────────────────┐ │ +│ │ 이번 주말에 맛집할까요? │ │ +│ └───────────────────────────────┘ │ +│ │ +│ 투표 유형: ○ 단일 선택 ● 복수 선택 │ +│ │ +│ 마감시간: [1일 ▼] │ +│ │ +│ 선택지 (최소 2개): │ +│ ┌───────────────────────────────┐ │ +│ │ 1. 토요일 (오후) [x] │ │ +│ │ 2. 토요일 (저녁) [x] │ │ +│ │ 3. 일요일 [x] │ │ +│ │ [+ 선택지 추가] │ │ +│ └───────────────────────────────┘ │ +│ │ +│ [삭제] │ +└─────────────────────────────────────┘ +``` + +### 투표 현황 화면 + +``` +📊 이번 주말에 맛집할까요? +⏰ 3일 후 마감 • 12명 참여 +[단일 선택] + +┌──────────────────────────────────┐ +│ ✅ 토요일 (오후) │ 45% │ +│ [홍길동] [철수] [영희] [민수] │ ████ │ +│ [상세보기 5명] │ │ +└──────────────────────────────────┘ + +┌──────────────────────────────────┐ +│ ⭕ 토요일 (저녁) │ 30% │ +│ [철수] [영희] │ ███ │ +└──────────────────────────────────┘ + +┌──────────────────────────────────┐ +│ ⭕ 일요일 │ 25% │ +│ [민수] │ ██ │ +└──────────────────────────────────┘ + +[투표하기] +``` + +### 투표 관리 화면 + +``` +┌─────────────────────────────────────┐ +│ 투표 관리 │ +├─────────────────────────────────────┤ +│ │ +│ 📊 이번 주말에 맛집할까요? │ +│ [단일 선택] • 0명 참여 │ +│ │ +│ [연필] [휴지통] │ +│ │ +│ ────────────────────────────── │ +│ │ +│ 질문: │ +│ ┌───────────────────────────────┐ │ +│ │ 이번 주말에 맛집할까요? │ │ +│ └───────────────────────────────┘ │ +│ │ +│ 투표 유형: [단일 선택 ▼] │ +│ │ +│ 선택지 (최소 2개): │ +│ ┌───────────────────────────────┐ │ +│ │ 토요일 (오후) [🗑️] │ │ +│ │ 토요일 (저녁) [🗑️] │ │ +│ │ 일요일 [🗑️] │ │ +│ │ [+ 선택지 추가] │ │ +│ └───────────────────────────────┘ │ +│ │ +│ 마감시간 연장: │ +│ [+1시간] [+6시간] [+1일] [+3일] [+1주]│ +│ │ +│ [취소] [저장] │ +└─────────────────────────────────────┘ +``` + +## 데이터베이스 + +### 테이블 구조 + +#### board_polls + +```sql +CREATE TABLE board_polls ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + post_id UUID NOT NULL REFERENCES board_posts(id) ON DELETE CASCADE, + question TEXT NOT NULL, + poll_type TEXT NOT NULL CHECK (poll_type IN ('single', 'multiple', 'date', 'anonymous')), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + allow_add_option BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + deleted_at TIMESTAMP +); + +CREATE INDEX idx_board_polls_post_id ON board_polls(post_id); +``` + +#### board_poll_options + +```sql +CREATE TABLE board_poll_options ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + poll_id UUID NOT NULL REFERENCES board_polls(id) ON DELETE CASCADE, + option_text TEXT NOT NULL, + option_order INTEGER NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_board_poll_options_poll_id ON board_poll_options(poll_id); +``` + +#### board_poll_votes + +```sql +CREATE TABLE board_poll_votes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + poll_id UUID NOT NULL REFERENCES board_polls(id) ON DELETE CASCADE, + option_id UUID NOT NULL REFERENCES board_poll_options(id) ON DELETE CASCADE, + member_id UUID REFERENCES members(id) ON DELETE CASCADE, + anonymous_id TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_board_poll_votes_poll_id ON board_poll_votes(poll_id); +``` + +### 제약 조건 + +- **단일 투표**: `member_id`당 1개의 선택지만 가능 +- **복수 투표**: 여러 선택지 선택 가능, 중복 방지 +- **익명 투표**: `member_id` NULL, `anonymous_id` 사용 +- **소프트 삭제**: `deleted_at`으로 삭제 처리 + +## API + +### GET /api/board/[id]/polls + +투표 현황 조회 + +**Response:** +```json +{ + "success": true, + "data": { + "polls": [ + { + "id": "uuid", + "question": "이번 주말에 맛집할까요?", + "pollType": "single", + "expiresAt": "2026-03-16T18:00:00Z", + "allowAddOption": true, + "isExpired": false, + "hasVoted": false, + "totalVotes": 12, + "options": [ + { + "id": "uuid", + "optionText": "토요일 (오후)", + "voteCount": 5, + "percentage": 45, + "voted": false, + "voters": [ + { + "memberId": "uuid", + "name": "홍길동", + "profileImage": null, + "discordId": "123456789", + "votedAt": "2026-03-13T10:00:00Z" + } + ] + } + ] + } + ] + } +} +``` + +### POST /api/board/[id]/polls/[pollId]/vote + +투표 참여 + +**Request:** +```json +{ + "optionIds": ["uuid"] +} +``` + +**Response:** +```json +{ + "success": true, + "message": "투표가 완료되었습니다." +} +``` + +### DELETE /api/board/[id]/polls/[pollId] + +투표 삭제 (참여자 0명일 때만 가능) + +**Response:** +```json +{ + "success": true, + "message": "투표가 삭제되었습니다." +} +``` + +### PATCH /api/board/[id]/polls/[pollId] + +투표 수정 (참여자 0명일 때만 가능) + +**Request:** +```json +{ + "question": "수정된 질문", + "pollType": "multiple", + "expiresAt": "2026-03-20T18:00:00Z", + "options": [ + { + "id": "uuid", + "optionText": "수정된 선택지" + } + ] +} +``` + +**Response:** +```json +{ + "success": true, + "message": "투표가 수정되었습니다." +} +``` + +## 사용자 흐름 + +### 투표 생성 + +1. 게시글 작성 페이지 접속 +2. "투표 추가" 버튼 클릭 +3. 질문, 유형, 선택지, 마감시간 입력 +4. 게시글 작성 완료 +5. 게시글과 함께 투표 자동 생성 + +### 투표 참여 + +1. 게시글 상세 페이지 접속 +2. 투표 현황 확인 +3. "투표하기" 버튼 클릭 +4. 선택지 선택 모달 표시 +5. 선택지 선택 후 "투표하기" 클릭 +6. 투표 완료 메시지 및 현황 갱신 + +### 투표 수정/삭제 + +1. 내 게시글 수정 페이지 접속 +2. "투표 관리" 버튼 클릭 +3. 수정할 투표의 연필 아이콘 클릭 +4. 질문, 유형, 선택지, 마감시간 수정 +5. "저장" 버튼 클릭 +6. 투표 수정 완료 + +## 제한 사항 + +- **투표 수정**: 참여자가 1명 이상이면 수정/삭제 불가 +- **마감시간**: 마감된 투표는 참여 불가 +- **중복 참여**: 단일 투표는 재투표 시 기존 투표 대체 +- **익명 투표**: 투표자 정보 완전히 비공개 +- **선택지**: 최소 2개 이상 필수 + +## 변경 사항 + +### v1.0.0 (2026-03-13) + +**추가 기능:** +- 게시판 투표 기능 구현 +- 4가지 투표 유형 지원 (단일/복수/날짜/익명) +- 투표 생성, 참여, 수정, 삭제 기능 +- 투표 현황 실시간 표시 +- 투표자 목록 상세보기 모달 +- 게시판 목록에 투표 아이콘 표시 + +**API 변경:** +- `GET /api/board` - `pollCount` 필드 추가 +- `POST /api/board` - `polls` 배열 지원 +- `GET /api/board/[id]/polls` - 투표 현황 조회 +- `POST /api/board/[id]/polls/[pollId]/vote` - 투표 참여 +- `DELETE /api/board/[id]/polls/[pollId]` - 투표 삭제 +- `PATCH /api/board/[id]/polls/[pollId]` - 투표 수정 + +**UI 변경:** +- `PollEditor` - 투표 생성 폼 +- `PollDisplay` - 투표 현황 표시 +- `PollVoteModal` - 투표 참여 모달 +- `PollManagerModal` - 투표 관리 모달 +- 게시판 목록에 투표 아이콘 (BarChart3) 표시 + +**DB 스키마:** +- `board_polls` 테이블 추가 +- `board_poll_options` 테이블 추가 +- `board_poll_votes` 테이블 추가 +- `poll_type` enum 추가 + +## 참고 + +- [기존 게시판 시스템](./26-03-06-ui-design-system.md) +- [DB 스키마](./26-03-06-schema-summary.md) +- [API 패턴](./26-03-06-patterns.md) diff --git a/packages/shared/src/db/schema.ts b/packages/shared/src/db/schema.ts index 36e540e..2586d6b 100644 --- a/packages/shared/src/db/schema.ts +++ b/packages/shared/src/db/schema.ts @@ -4,6 +4,7 @@ import { index, integer, jsonb, + pgEnum, pgTable, real, serial, @@ -417,6 +418,88 @@ export const boardComments = pgTable( }) ); +// ── Board Polls ───────────────────────────────────────────────────────── + +export const PollType = { + SINGLE: 'single', + MULTIPLE: 'multiple', + DATE: 'date', + ANONYMOUS: 'anonymous', +} as const; + +export type PollTypeType = (typeof PollType)[keyof typeof PollType]; + +export const pollTypeEnum = pgEnum('poll_type', [ + 'text', + 'date', +]); + +export const boardPolls = pgTable( + 'board_polls', + { + id: uuid('id').primaryKey().defaultRandom(), + postId: uuid('post_id') + .notNull() + .references(() => boardPosts.id, { onDelete: 'cascade' }), + question: text('question').notNull(), + pollType: pollTypeEnum('poll_type').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + allowMultiple: boolean('allow_multiple').default(false), + isAnonymous: boolean('is_anonymous').default(false), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + deletedAt: timestamp('deleted_at', { withTimezone: true }), + }, + (table) => ({ + postIdIdx: index('idx_board_polls_post_id').on(table.postId), + }) +); + +// Add unique constraint via raw SQL (need to push manually) +// CREATE UNIQUE INDEX IF NOT EXISTS unique_active_poll_per_post +// ON board_polls(post_id) +// WHERE deleted_at IS NULL; + +export const boardPollOptions = pgTable( + 'board_poll_options', + { + id: uuid('id').primaryKey().defaultRandom(), + pollId: uuid('poll_id') + .notNull() + .references(() => boardPolls.id, { onDelete: 'cascade' }), + optionText: text('option_text').notNull(), + optionOrder: integer('option_order').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + pollIdIdx: index('idx_board_poll_options_poll_id').on(table.pollId), + }) +); + +export const boardPollVotes = pgTable( + 'board_poll_votes', + { + id: uuid('id').primaryKey().defaultRandom(), + pollId: uuid('poll_id') + .notNull() + .references(() => boardPolls.id, { onDelete: 'cascade' }), + optionId: uuid('option_id') + .notNull() + .references(() => boardPollOptions.id, { onDelete: 'cascade' }), + memberId: uuid('member_id').references(() => members.id, { + onDelete: 'cascade', + }), + anonymousId: text('anonymous_id'), + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(), + }, + (table) => ({ + pollIdIdx: index('idx_board_poll_votes_poll_id').on(table.pollId), + optionIdIdx: index('idx_board_poll_votes_option_id').on(table.optionId), + memberIdIdx: index('idx_board_poll_votes_member_id').on(table.memberId), + }) +); + // ============================================ // Relations // ============================================ @@ -519,6 +602,7 @@ export const boardPostsRelations = relations(boardPosts, ({ one, many }) => ({ references: [members.id], }), comments: many(boardComments), + polls: many(boardPolls), })); export const boardCommentsRelations = relations(boardComments, ({ one, many }) => ({ @@ -538,6 +622,41 @@ export const boardCommentsRelations = relations(boardComments, ({ one, many }) = children: many(boardComments, { relationName: 'parentChild' }), })); +export const boardPollsRelations = relations(boardPolls, ({ one, many }) => ({ + post: one(boardPosts, { + fields: [boardPolls.postId], + references: [boardPosts.id], + }), + options: many(boardPollOptions), + votes: many(boardPollVotes), +})); + +export const boardPollOptionsRelations = relations(boardPollOptions, ({ + one, + many, +}) => ({ + poll: one(boardPolls, { + fields: [boardPollOptions.pollId], + references: [boardPolls.id], + }), + votes: many(boardPollVotes), +})); + +export const boardPollVotesRelations = relations(boardPollVotes, ({ one }) => ({ + poll: one(boardPolls, { + fields: [boardPollVotes.pollId], + references: [boardPolls.id], + }), + option: one(boardPollOptions, { + fields: [boardPollVotes.optionId], + references: [boardPollOptions.id], + }), + member: one(members, { + fields: [boardPollVotes.memberId], + references: [members.id], + }), +})); + // ============================================ // Type Exports (for use in application code) // ============================================ @@ -583,3 +702,12 @@ export type NewBoardPost = typeof boardPosts.$inferInsert; export type BoardComment = typeof boardComments.$inferSelect; export type NewBoardComment = typeof boardComments.$inferInsert; + +export type BoardPoll = typeof boardPolls.$inferSelect; +export type NewBoardPoll = typeof boardPolls.$inferInsert; + +export type BoardPollOption = typeof boardPollOptions.$inferSelect; +export type NewBoardPollOption = typeof boardPollOptions.$inferInsert; + +export type BoardPollVote = typeof boardPollVotes.$inferSelect; +export type NewBoardPollVote = typeof boardPollVotes.$inferInsert; diff --git a/packages/web/package.json b/packages/web/package.json index ccc71a9..d3b6fa2 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", @@ -38,6 +39,7 @@ "@tiptap/starter-kit": "^3.20.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "drizzle-orm": "^0.33.0", "feedsmith": "^2.9.0", "framer-motion": "^12.35.1", @@ -46,6 +48,7 @@ "next": "16.1.6", "next-themes": "^0.4.6", "react": "19.2.4", + "react-day-picker": "^9.14.0", "react-dom": "19.2.4", "sonner": "^2.0.7", "tailwind-merge": "^2.3.0", diff --git a/packages/web/src/app/(user)/board/[id]/edit/page.tsx b/packages/web/src/app/(user)/board/[id]/edit/page.tsx index da60dd8..6198609 100644 --- a/packages/web/src/app/(user)/board/[id]/edit/page.tsx +++ b/packages/web/src/app/(user)/board/[id]/edit/page.tsx @@ -3,8 +3,9 @@ import { useEffect, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { toast } from 'sonner'; -import { ArrowLeft, Loader2, Lock, Megaphone } from 'lucide-react'; +import { ArrowLeft, Loader2, Lock, Megaphone, BarChart3 } from 'lucide-react'; import { TiptapEditor } from '@/components/board/tiptap-editor'; +import { PollManagerModal, type Poll } from '@/components/board/poll-manager-modal'; import { BOARD_CATEGORIES } from '@/lib/board-config'; import { Select, @@ -39,6 +40,8 @@ export default function BoardEditPage() { const [initialContent, setInitialContent] = useState(null); const [isSecret, setIsSecret] = useState(false); const [isNoticeBanner, setIsNoticeBanner] = useState(false); + const [polls, setPolls] = useState([]); + const [pollManagerOpen, setPollManagerOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -48,10 +51,11 @@ export default function BoardEditPage() { setFetchError(null); try { - const [postRes, meRes, adminRes] = await Promise.all([ + const [postRes, meRes, adminRes, pollsRes] = await Promise.all([ fetch(`/api/board/${id}`), fetch('/api/auth/me'), fetch('/api/admin/check'), + fetch(`/api/board/${id}/polls`), ]); const adminData = adminRes.ok ? await adminRes.json() : null; @@ -94,6 +98,12 @@ export default function BoardEditPage() { setContent(post.content); } setContentText(post.contentText ?? ''); + + // Load polls + if (pollsRes.ok) { + const pollsResult = await pollsRes.json(); + setPolls(pollsResult.data.polls || []); + } } catch { setFetchError('서버 오류가 발생했습니다. 다시 시도해주세요.'); } finally { @@ -329,6 +339,16 @@ export default function BoardEditPage() { > 취소 + + + )} + + + ))} + + + {/* Action button */} + {canVote && ( +
+ {poll.hasVoted && ( + + )} + +
+ )} + + {poll.isExpired && !poll.hasVoted && ( +
+ 마감된 투표입니다 +
+ )} + + + {/* Vote modal */} + + + {/* Voters modal */} + + + + + 투표자 목록 + + {selectedOption?.optionText} + + + + +
+ {selectedOption?.voters.map((voter) => ( + setVotersModalOpen(false)} + > +
+ +
+
+
{voter.name}
+ {voter.nickname && voter.nickname !== voter.name && ( +
@{voter.nickname}
+ )} +
+ {new Date(voter.votedAt).toLocaleString('ko-KR', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} +
+
+ + ))} +
+ +
+ +
+
+
+ + {/* Cancel vote dialog */} + + + ); +} diff --git a/packages/web/src/components/board/poll-editor.tsx b/packages/web/src/components/board/poll-editor.tsx new file mode 100644 index 0000000..8ae5324 --- /dev/null +++ b/packages/web/src/components/board/poll-editor.tsx @@ -0,0 +1,615 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { Plus, Trash2, BarChart3, Calendar, Clock, Edit2, Check, Lock } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Calendar as CalendarComponent } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { format } from 'date-fns'; +import { ko } from 'date-fns/locale'; + +// ───────────────────────────────────────────── +// Types +// ───────────────────────────────────────────── + +export type PollType = 'text' | 'date'; + +export interface Poll { + question: string; + pollType: PollType; + expiresAt: string; // ISO 8601 + allowMultiple: boolean; + isAnonymous: boolean; + options: string[]; +} + +interface PollEditorProps { + polls: Poll[]; + onPollsChange: (polls: Poll[]) => void; +} + +// ───────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────── + +const POLL_TYPE_OPTIONS = [ + { value: 'text' as const, label: '텍스트 투표' }, + { value: 'date' as const, label: '날짜 투표' }, +]; + +// ───────────────────────────────────────────── +// Component +// ───────────────────────────────────────────── + +export function PollEditor({ polls, onPollsChange }: PollEditorProps) { + const [newPoll, setNewPoll] = useState>(() => ({ + pollType: 'text', + allowMultiple: false, + isAnonymous: false, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + })); + const [options, setOptions] = useState(['', '']); + const [calendarOpen, setCalendarOpen] = useState(false); + const [dateOptions, setDateOptions] = useState([]); + const [datePickerOpen, setDatePickerOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + + const handleAddPoll = () => { + // Check if poll already exists + if (polls.length >= 1) { + toast.error('게시글당 투표는 1개만 생성할 수 있습니다.'); + return; + } + + // Validation + if (!newPoll.question?.trim()) { + toast.error('질문을 입력해주세요.'); + return; + } + + if (!newPoll.pollType) { + toast.error('투표 유형을 선택해주세요.'); + return; + } + + // For date polls, use selected dates as options + let finalOptions: string[]; + if (newPoll.pollType === 'date') { + if (dateOptions.length < 2) { + toast.error('날짜는 최소 2개 이상 선택해야 합니다.'); + return; + } + // Format dates as strings + finalOptions = dateOptions + .sort((a, b) => a.getTime() - b.getTime()) + .map(date => format(date, 'yyyy-MM-dd')); + } else { + const validOptions = options.filter((opt) => opt.trim()); + if (validOptions.length < 2) { + toast.error('선택지는 최소 2개 이상이어야 합니다.'); + return; + } + finalOptions = validOptions; + } + + if (!newPoll.expiresAt) { + toast.error('마감시간을 설정해주세요.'); + return; + } + + // Add poll + const poll: Poll = { + question: newPoll.question.trim(), + pollType: newPoll.pollType, + allowMultiple: newPoll.allowMultiple || false, + isAnonymous: newPoll.isAnonymous || false, + expiresAt: newPoll.expiresAt, + options: finalOptions, + }; + + onPollsChange([...polls, poll]); + + // Reset form + setNewPoll({ + pollType: 'text', + allowMultiple: false, + isAnonymous: false, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }); + setOptions(['', '']); + setDateOptions([]); + }; + + const handleRemovePoll = (index: number) => { + onPollsChange(polls.filter((_, i) => i !== index)); + }; + + const handleEditPoll = (index: number) => { + const poll = polls[index]; + if (!poll) return; + + setNewPoll({ + question: poll.question, + pollType: poll.pollType, + allowMultiple: poll.allowMultiple, + isAnonymous: poll.isAnonymous, + expiresAt: poll.expiresAt, + }); + + // Set options based on poll type + if (poll.pollType === 'date') { + setDateOptions(poll.options.map(dateStr => { + const parts = dateStr.split('-').map(Number); + const [year, month, day] = parts; + if (!year || !month || !day) return new Date(); + return new Date(year, month - 1, day); + })); + } else { + setOptions(poll.options); + } + + setEditingIndex(index); + }; + + const handleUpdatePoll = () => { + if (editingIndex === null) return; + + // Validation + if (!newPoll.question?.trim()) { + toast.error('질문을 입력해주세요.'); + return; + } + + if (!newPoll.pollType) { + toast.error('투표 유형을 선택해주세요.'); + return; + } + + // For date polls, use selected dates as options + let finalOptions: string[]; + if (newPoll.pollType === 'date') { + if (dateOptions.length < 2) { + toast.error('날짜는 최소 2개 이상 선택해야 합니다.'); + return; + } + finalOptions = dateOptions + .sort((a, b) => a.getTime() - b.getTime()) + .map(date => format(date, 'yyyy-MM-dd')); + } else { + const validOptions = options.filter((opt) => opt.trim()); + if (validOptions.length < 2) { + toast.error('선택지는 최소 2개 이상이어야 합니다.'); + return; + } + finalOptions = validOptions; + } + + if (!newPoll.expiresAt) { + toast.error('마감시간을 설정해주세요.'); + return; + } + + // Update poll + const updatedPolls = [...polls]; + updatedPolls[editingIndex] = { + question: newPoll.question.trim(), + pollType: newPoll.pollType, + allowMultiple: newPoll.allowMultiple || false, + isAnonymous: newPoll.isAnonymous || false, + expiresAt: newPoll.expiresAt, + options: finalOptions, + }; + onPollsChange(updatedPolls); + + // Reset form + setNewPoll({ + pollType: 'text', + allowMultiple: false, + isAnonymous: false, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }); + setOptions(['', '']); + setDateOptions([]); + setEditingIndex(null); + }; + + const handleCancelEdit = () => { + setNewPoll({ + pollType: 'text', + allowMultiple: false, + isAnonymous: false, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + }); + setOptions(['', '']); + setDateOptions([]); + setEditingIndex(null); + }; + + const handleOptionChange = (index: number, value: string) => { + const newOptions = [...options]; + newOptions[index] = value; + setOptions(newOptions); + }; + + const handleAddOption = () => { + setOptions([...options, '']); + }; + + const handleRemoveOption = (index: number) => { + if (options.length <= 2) { + toast.error('선택지는 최소 2개 이상이어야 합니다.'); + return; + } + setOptions(options.filter((_, i) => i !== index)); + }; + + return ( +
+ {/* Existing polls */} + {polls.length > 0 && editingIndex === null && ( +
+ + {polls.map((poll, index) => ( +
+
+ + {poll.question} + + ({POLL_TYPE_OPTIONS.find((p) => p.value === poll.pollType)?.label}) + +
+
+ + +
+
+ ))} +
+ )} + + {/* New poll form - show if no poll exists OR editing */} + {(polls.length === 0 || editingIndex !== null) && ( +
+
+
+ +
+ + {/* Question */} +
+ + setNewPoll({ ...newPoll, question: e.target.value })} + className="text-sm" + /> +
+ + {/* Poll type */} +
+ +
+ + +
+
+ + {/* Poll options */} +
+ +
+ + +
+
+ + {/* Expiry */} +
+ + + + + + +
+ {/* Date picker */} +
+ + { + if (date) { + // Preserve the time from current expiresAt + const currentExpiresAt = newPoll.expiresAt + ? new Date(newPoll.expiresAt) + : new Date(); + date.setHours(currentExpiresAt.getHours(), currentExpiresAt.getMinutes()); + setNewPoll({ + ...newPoll, + expiresAt: date.toISOString(), + }); + } + }} + disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))} + initialFocus + className="rounded-md border" + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100", + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", + day: "h-9 w-9 p-0 font-normal aria-selected:opacity-100 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors", + day_range_end: "day-range-end", + day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: "day-outside text-muted-foreground opacity-50", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + }} + /> +
+ + {/* Time picker */} +
+ +
+ + { + const [hours, minutes] = e.target.value.split(':').map(Number); + if (newPoll.expiresAt) { + const date = new Date(newPoll.expiresAt); + date.setHours(hours || 0, minutes || 0); + setNewPoll({ + ...newPoll, + expiresAt: date.toISOString(), + }); + } + }} + className="flex-1" + /> +
+
+
+
+
+
+ + {/* Options or Date Picker */} + {newPoll.pollType === 'date' ? ( +
+ + + + + + +
+ { + if (dates) { + setDateOptions(dates as Date[]); + } + }} + disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))} + initialFocus + className="rounded-md border" + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100", + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", + day: "h-9 w-9 p-0 font-normal aria-selected:opacity-100 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors", + day_range_end: "day-range-end", + day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: "day-outside text-muted-foreground opacity-50", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + }} + /> +
+
+
+ + {/* Selected dates */} + {dateOptions.length > 0 && ( +
+ {dateOptions + .sort((a, b) => a.getTime() - b.getTime()) + .map((date, index) => ( +
+ {format(date, 'MM월 dd일 (E)', { locale: ko })} + +
+ ))} +
+ )} +
+ ) : ( +
+ + {options.map((option, index) => ( +
+ handleOptionChange(index, e.target.value)} + className="flex-1 text-sm" + /> + +
+ ))} + +
+ )} + + {/* Add/Update poll button */} +
+ {editingIndex !== null && ( + + )} + +
+
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/board/poll-manager-modal.tsx b/packages/web/src/components/board/poll-manager-modal.tsx new file mode 100644 index 0000000..f0eb7eb --- /dev/null +++ b/packages/web/src/components/board/poll-manager-modal.tsx @@ -0,0 +1,507 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Trash2, Edit2, Users, Lock, Calendar, Clock, Check } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Calendar as CalendarComponent } from '@/components/ui/calendar'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { format } from 'date-fns'; +import { ko } from 'date-fns/locale'; +import { toast } from 'sonner'; + +// ───────────────────────────────────────────── +// Types +// ───────────────────────────────────────────── + +export type PollType = 'text' | 'date'; + +export interface Poll { + id: string; + question: string; + pollType: PollType; + allowMultiple: boolean; + isAnonymous: boolean; + expiresAt: string; + totalVotes: number; + options: Array<{ + id: string; + optionText: string; + voteCount: number; + }>; +} + +interface PollManagerModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + postId: string; + initialPolls: Poll[]; + onPollsUpdate: (polls: Poll[]) => void; +} + +// ───────────────────────────────────────────── +// Component +// ───────────────────────────────────────────── + +export function PollManagerModal({ + open, + onOpenChange, + postId, + initialPolls, + onPollsUpdate, +}: PollManagerModalProps) { + const [polls, setPolls] = useState([]); + const [editingPoll, setEditingPoll] = useState(null); + const [loading, setLoading] = useState(false); + const [calendarOpen, setCalendarOpen] = useState(false); + + // Load polls when modal opens + useEffect(() => { + if (open) { + setPolls(initialPolls); + } + }, [open, initialPolls]); + + // Check if poll can be edited (no votes) + const canEditPoll = (poll: Poll) => poll.totalVotes === 0; + + const handleDeletePoll = async (pollId: string) => { + const poll = polls.find((p) => p.id === pollId); + if (!poll) return; + + if (!canEditPoll(poll)) { + toast.error('투표 참여자가 있어 수정할 수 없습니다.'); + return; + } + + if (!confirm(`"${poll.question}" 투표를 삭제하시겠습니까?`)) { + return; + } + + setLoading(true); + try { + const res = await fetch(`/api/board/${postId}/polls/${pollId}`, { + method: 'DELETE', + }); + + const result = await res.json(); + + if (!res.ok) { + toast.error(result.message || result.error?.message || '투표 삭제에 실패했습니다.'); + return; + } + + toast.success('투표가 삭제되었습니다.'); + const updated = polls.filter((p) => p.id !== pollId); + setPolls(updated); + onPollsUpdate(updated); + } catch { + toast.error('서버 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + }; + + const handleUpdatePoll = async (pollId: string) => { + const poll = polls.find((p) => p.id === pollId); + if (!poll || !editingPoll) return; + + if (!canEditPoll(poll)) { + toast.error('투표 참여자가 있어 수정할 수 없습니다.'); + return; + } + + // Validation + if (!editingPoll.question?.trim()) { + toast.error('질문을 입력해주세요.'); + return; + } + + const validOptions = editingPoll.options?.filter((opt) => opt.optionText.trim()) || []; + if (validOptions.length < 2) { + toast.error('선택지는 최소 2개 이상이어야 합니다.'); + return; + } + + setLoading(true); + try { + const res = await fetch(`/api/board/${postId}/polls/${pollId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + question: editingPoll.question.trim(), + pollType: editingPoll.pollType, + allowMultiple: editingPoll.allowMultiple, + isAnonymous: editingPoll.isAnonymous, + expiresAt: editingPoll.expiresAt, + options: validOptions.map((opt, idx) => ({ + id: opt.id, + optionText: opt.optionText.trim(), + optionOrder: idx, + })), + }), + }); + + const result = await res.json(); + + if (!res.ok) { + toast.error(result.message || result.error?.message || '투표 수정에 실패했습니다.'); + return; + } + + toast.success('투표가 수정되었습니다.'); + + // Update local state + const updated = polls.map((p) => + p.id === pollId ? { ...p, ...editingPoll } : p + ); + setPolls(updated); + onPollsUpdate(updated); + setEditingPoll(null); + } catch { + toast.error('서버 오류가 발생했습니다.'); + } finally { + setLoading(false); + } + }; + + return ( + + + + 투표 관리 + + 투표를 수정하거나 삭제할 수 있습니다. 투표 참여자가 있으면 수정할 수 없습니다. + + + +
+ {polls.length === 0 ? ( +
+ 현재 등록된 투표가 없습니다. +
+ ) : ( + polls.map((poll) => ( +
+ {/* Poll header */} +
+
+
+

{poll.question}

+ + {poll.pollType === 'text' ? '텍스트' : '날짜'} + {poll.allowMultiple && ' · 복수'} + {poll.isAnonymous && ' · 익명'} + +
+
+ + + {poll.totalVotes}명 참여 + + {poll.totalVotes > 0 && ( + + + 수정 불가 + + )} +
+
+ + {/* Actions */} + {canEditPoll(poll) && ( +
+ + +
+ )} +
+ + {/* Editing form */} + {editingPoll?.id === poll.id && ( +
+
+ + + setEditingPoll({ ...editingPoll, question: e.target.value }) + } + placeholder="투표 질문" + className="text-sm" + /> +
+ +
+ +
+ + +
+

투표 유형은 생성 후 변경할 수 없습니다.

+
+ +
+ +
+ + +
+
+ +
+ +
+ {editingPoll.options.map((option, idx) => ( +
+ { + const newOptions = [...editingPoll.options]; + if (newOptions[idx]) { + newOptions[idx] = { + ...newOptions[idx], + optionText: e.target.value, + id: newOptions[idx].id || crypto.randomUUID(), + voteCount: newOptions[idx].voteCount || 0, + }; + setEditingPoll({ ...editingPoll, options: newOptions }); + } + }} + placeholder={`선택지 ${idx + 1}`} + className="flex-1 text-sm" + /> + {editingPoll.options.length > 2 && ( + + )} +
+ ))} + +
+
+ +
+ +
+ {/* Date picker */} + + + + + +
+ { + if (date) { + const currentExpiresAt = new Date(editingPoll.expiresAt); + date.setHours(currentExpiresAt.getHours(), currentExpiresAt.getMinutes()); + setEditingPoll({ + ...editingPoll, + expiresAt: date.toISOString(), + }); + } + setCalendarOpen(false); + }} + disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))} + initialFocus + className="rounded-md border" + classNames={{ + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0", + month: "space-y-4", + caption: "flex justify-center pt-1 relative items-center", + caption_label: "text-sm font-medium", + nav: "space-x-1 flex items-center", + nav_button: "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100", + nav_button_previous: "absolute left-1", + nav_button_next: "absolute right-1", + table: "w-full border-collapse space-y-1", + head_row: "flex", + head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]", + row: "flex w-full mt-2", + cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20", + day: "h-9 w-9 p-0 font-normal aria-selected:opacity-100 hover:bg-accent hover:text-accent-foreground rounded-md transition-colors", + day_range_end: "day-range-end", + day_selected: "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: "day-outside text-muted-foreground opacity-50", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + }} + /> +
+
+
+ + {/* Time picker */} +
+ + { + const [hours, minutes] = e.target.value.split(':').map(Number); + const date = new Date(editingPoll.expiresAt); + date.setHours(hours || 0, minutes || 0); + setEditingPoll({ + ...editingPoll, + expiresAt: date.toISOString(), + }); + }} + className="flex-1" + /> +
+
+
+ +
+ + +
+
+ )} + + {/* Options preview (read-only) */} + {editingPoll?.id !== poll.id && ( +
+ {poll.options.map((option) => ( +
+ {option.optionText} + + {option.voteCount}표 + +
+ ))} +
+ )} +
+ )) + )} +
+ +
+ +
+
+
+ ); +} diff --git a/packages/web/src/components/board/poll-vote-modal.tsx b/packages/web/src/components/board/poll-vote-modal.tsx new file mode 100644 index 0000000..3b8fbf7 --- /dev/null +++ b/packages/web/src/components/board/poll-vote-modal.tsx @@ -0,0 +1,149 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Loader2, CheckCircle2, Circle } from 'lucide-react'; +import type { Poll } from './poll-display'; +import { formatPollDate } from '@/lib/date-utils'; + +interface PollVoteModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + poll: Poll; + onVote: (optionIds: string[]) => void; +} + +export function PollVoteModal({ + open, + onOpenChange, + poll, + onVote, +}: PollVoteModalProps) { + const [selectedOptions, setSelectedOptions] = useState([]); + const [submitting, setSubmitting] = useState(false); + + // Multiple selections allowed if poll allows it + const isMultiple = poll.allowMultiple; + + const handleOptionClick = (optionId: string) => { + if (isMultiple) { + setSelectedOptions((prev) => + prev.includes(optionId) + ? prev.filter((id) => id !== optionId) + : [...prev, optionId] + ); + } else { + setSelectedOptions([optionId]); + } + }; + + const handleVote = async () => { + if (selectedOptions.length === 0) return; + if (submitting) return; // Prevent double submission + + setSubmitting(true); + try { + await onVote(selectedOptions); + setSelectedOptions([]); + } finally { + setSubmitting(false); + } + }; + + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + setSelectedOptions([]); + } + onOpenChange(newOpen); + }; + + return ( + + + + 투표하기 + + {poll.question} + + + +
+ {poll.options.map((option) => { + const isSelected = selectedOptions.includes(option.id); + return ( + + ); + })} +
+ + + + + +
+
+ ); +} diff --git a/packages/web/src/components/ui/calendar.tsx b/packages/web/src/components/ui/calendar.tsx new file mode 100644 index 0000000..a623682 --- /dev/null +++ b/packages/web/src/components/ui/calendar.tsx @@ -0,0 +1,213 @@ +"use client" + +import * as React from "react" +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker" + +import { cn } from "@/lib/utils" +import { Button, buttonVariants } from "@/components/ui/button" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames() + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "bg-popover absolute inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-[--cell-size] select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-muted-foreground select-none text-[0.8rem]", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", + defaultClassNames.day + ), + range_start: cn( + "bg-accent rounded-l-md", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ) + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ) + } + + if (orientation === "right") { + return ( + + ) + } + + return ( + + ) + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ) + }, + ...components, + }} + {...props} + /> + ) +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames() + + const ref = React.useRef(null) + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus() + }, [modifiers.focused]) + + return ( +