diff --git a/apps/desktop/bun.lockb b/apps/desktop/bun.lockb index 99f23a24d0..48c5c4da63 100755 Binary files a/apps/desktop/bun.lockb and b/apps/desktop/bun.lockb differ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 32805334e7..0ddbe5dfa8 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,15 +15,16 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-fs": "^2.0.3", "@tauri-apps/plugin-shell": "^2", + "@tauri-apps/plugin-stronghold": "^2.0.0", + "@tauri-apps/plugin-updater": "^2.0.0", "@tiptap/extension-highlight": "^2.10.3", "@tiptap/extension-typography": "^2.10.3", "@tiptap/pm": "^2.10.3", "@tiptap/react": "^2.10.3", "@tiptap/starter-kit": "^2.10.3", "cmdk": "^1.0.4", + "date-fns": "^4.1.0", "lucide-react": "^0.468.0", - "@tauri-apps/plugin-stronghold": "^2.0.0", - "@tauri-apps/plugin-updater": "^2.0.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-resizable-panels": "^2.1.7", diff --git a/apps/desktop/src/components/NavBar.tsx b/apps/desktop/src/components/NavBar.tsx index 7634759a25..3aa1bfec8f 100644 --- a/apps/desktop/src/components/NavBar.tsx +++ b/apps/desktop/src/components/NavBar.tsx @@ -11,8 +11,10 @@ export default function NavBar() { const [isSearchOpen, setIsSearchOpen] = useState(false); const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isExportMenuOpen, setIsExportMenuOpen] = useState(false); const searchRef = useRef(null); const profileRef = useRef(null); + const exportRef = useRef(null); const navigate = useNavigate(); const location = useLocation(); @@ -26,6 +28,10 @@ export default function NavBar() { setIsProfileMenuOpen(false); }); + useClickOutside(exportRef, () => { + setIsExportMenuOpen(false); + }); + const handleNewNote = () => { const noteId = crypto.randomUUID(); navigate(`/note/${noteId}`); @@ -87,7 +93,7 @@ export default function NavBar() { {isProfileMenuOpen && (
-
홍길동
+
John Snow
hong@example.com
@@ -163,16 +169,40 @@ export default function NavBar() {
- {/* Share Link Button - Only show on note page */} + {/* Share Link Button - Changed to Export dropdown */} {isNotePage && ( - +
+ + {isExportMenuOpen && ( +
+
+ + +
+
+ )} +
)} {/* New Note Button */} diff --git a/apps/desktop/src/components/note/LiveCaptionDock.tsx b/apps/desktop/src/components/note/LiveCaptionDock.tsx deleted file mode 100644 index 93f004689d..0000000000 --- a/apps/desktop/src/components/note/LiveCaptionDock.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { useEffect, useRef, useState } from "react"; - -interface LiveCaptionDockProps { - currentTranscript: string; -} - -export default function LiveCaptionDock({ - currentTranscript, -}: LiveCaptionDockProps) { - const containerRef = useRef(null); - const [lines, setLines] = useState([]); - - useEffect(() => { - if (!currentTranscript) return; - - // 새로운 텍스트가 들어오면 lines 배열에 추가 - setLines((prev) => { - const newLines = [...prev]; - if (currentTranscript !== newLines[newLines.length - 1]) { - newLines.push(currentTranscript); - } - // 최대 5개의 최근 라인만 유지 - return newLines.slice(-5); - }); - }, [currentTranscript]); - - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - // 자동 스크롤 - container.scrollTop = container.scrollHeight; - }, [lines]); - - return ( -
-
- {lines.map((line, index) => ( -
- {line} -
- ))} -
-
- ); -} diff --git a/apps/desktop/src/components/note/NoteEditor.tsx b/apps/desktop/src/components/note/NoteEditor.tsx index b42d0f4f2d..6e3acfdf8f 100644 --- a/apps/desktop/src/components/note/NoteEditor.tsx +++ b/apps/desktop/src/components/note/NoteEditor.tsx @@ -46,27 +46,39 @@ export default function NoteEditor({ content, onChange }: NoteEditorProps) { }, [content, editor]); return ( -
{ - if (!editor) return; +
+
+ { + if (!editor) return; - // 클릭한 위치의 Y 좌표 - const clickY = e.clientY; - // 에디터의 마지막 위치의 Y 좌표 - const editorRect = editor.view.dom.getBoundingClientRect(); - const lastLineY = editorRect.bottom; + // 클릭한 위치의 Y 좌표 + const clickY = e.clientY; + // 에디터의 마지막 위치의 Y 좌표 + const editorRect = editor.view.dom.getBoundingClientRect(); + const lastLineY = editorRect.bottom; - // 클릭 위치가 마지막 줄보다 아래인 경우 새로운 줄 추가 - if (clickY > lastLineY) { - editor.commands.setTextSelection(editor.state.doc.content.size); - editor.commands.enter(); - } + // 클릭 위치가 마지막 줄보다 아래인 경우 + if (clickY > lastLineY) { + // 마지막 위치로 커서 이동 + editor.commands.setTextSelection(editor.state.doc.content.size); - editor.commands.focus(); - }} - > - + // 마지막 노드가 빈 텍스트 블록이 아닌 경우에만 새 줄 추가 + const lastNode = editor.state.doc.lastChild; + if ( + lastNode && + (!lastNode.isTextblock || lastNode.content.size > 0) + ) { + editor.commands.enter(); + } + } + + editor.commands.focus(); + }} + /> +
); } diff --git a/apps/desktop/src/components/note/NoteHeader.tsx b/apps/desktop/src/components/note/NoteHeader.tsx index d2152a4a18..bcea3ccf67 100644 --- a/apps/desktop/src/components/note/NoteHeader.tsx +++ b/apps/desktop/src/components/note/NoteHeader.tsx @@ -1,4 +1,5 @@ -import type { Note, CalendarEvent } from "../../types"; +import type { Note } from "../../types"; +import { formatMeetingTime } from "../../utils/time"; import NoteControl from "./NoteControl"; interface NoteHeaderProps { @@ -28,24 +29,8 @@ export default function NoteHeader({ onStartRecording, onPauseResume, }: NoteHeaderProps) { - const formatMeetingTime = (start: CalendarEvent["start"]) => { - const now = new Date(); - const startTime = start.dateTime - ? new Date(start.dateTime) - : start.date - ? new Date(start.date) - : null; - - if (!startTime) return ""; - - const diff = Math.floor((now.getTime() - startTime.getTime()) / 1000); - const mins = Math.floor(diff / 60); - const secs = diff % 60; - return `${mins}:${secs.toString().padStart(2, "0")}`; - }; - return ( -
+
- {note?.calendarEvent && ( -
+
+ {note?.calendarEvent && (
{note.calendarEvent.summary}
@@ -65,8 +50,18 @@ export default function NoteHeader({ {formatMeetingTime(note.calendarEvent.end)}
-
- )} + )} + {note?.tags && + note.tags.length > 0 && + note.tags.map((tag) => ( + + {tag} + + ))} +
-
+
-
- updateState({ content })} - /> - -
+ updateState({ content })} + />
diff --git a/apps/desktop/src/types.ts b/apps/desktop/src/types.ts index bc25a3d6f4..3f95a05d30 100644 --- a/apps/desktop/src/types.ts +++ b/apps/desktop/src/types.ts @@ -2,47 +2,107 @@ import type { Node as PMNode } from "@tiptap/pm/model"; // Google Calendar Event Type export interface CalendarEvent { - kind: string; + // 이벤트의 종류를 나타내는 식별자 (항상 "calendar#event") + kind: "calendar#event"; + // 리소스의 ETag + etag?: string; + // 이벤트의 고유 식별자 id: string; - status: string; + // 이벤트의 상태 (confirmed: 확정, tentative: 임시, cancelled: 취소) + status: "confirmed" | "tentative" | "cancelled"; + // Google Calendar 웹 UI에서 이벤트로 연결되는 절대 링크 htmlLink: string; + // 이벤트 생성 시간 (RFC3339 타임스탬프) created: string; + // 이벤트 마지막 수정 시간 (RFC3339 타임스탬프) updated: string; + // 이벤트 제목 summary: string; + // 이벤트 설명 (HTML 포함 가능) description?: string; + // 이벤트 위치 (자유 형식 텍스트) location?: string; + // 이벤트 색상 ID (colors 엔드포인트의 이벤트 섹션 참조) + colorId?: string; + // 이벤트 생성자 정보 creator: { - id?: string; - email: string; - displayName?: string; - self?: boolean; + id?: string; // 생성자의 Profile ID + email: string; // 생성자의 이메일 + displayName?: string; // 생성자의 표시 이름 + self?: boolean; // 생성자가 현재 캘린더의 소유자인지 여부 }; + // 이벤트 주최자 정보 organizer: { - id?: string; - email: string; - displayName?: string; - self?: boolean; + id?: string; // 주최자의 Profile ID + email: string; // 주최자의 이메일 + displayName?: string; // 주최자의 표시 이름 + self?: boolean; // 주최자가 현재 캘린더의 소유자인지 여부 }; + // 이벤트 시작 시간 정보 start: { - date?: string; - dateTime?: string; - timeZone?: string; + date?: string; // 종일 이벤트인 경우 날짜 ("yyyy-mm-dd" 형식) + dateTime?: string; // 시간이 있는 이벤트의 경우 날짜와 시간 (RFC3339 형식) + timeZone?: string; // 시간대 (IANA Time Zone Database 이름 형식) }; + // 이벤트 종료 시간 정보 end: { + date?: string; // 종일 이벤트인 경우 날짜 + dateTime?: string; // 시간이 있는 이벤트의 경우 날짜와 시간 + timeZone?: string; // 시간대 + }; + // 종료 시간이 미지정인지 여부 + endTimeUnspecified?: boolean; + // 반복 일정 규칙 (RFC5545 형식의 RRULE, EXRULE, RDATE, EXDATE) + recurrence?: string[]; + // 반복 이벤트의 ID (반복 이벤트의 인스턴스인 경우) + recurringEventId?: string; + // 원래 시작 시간 (반복 이벤트가 이동된 경우) + originalStartTime?: { date?: string; dateTime?: string; timeZone?: string; }; + // 일정 표시 방식 (opaque: 바쁨, transparent: 한가함) + transparency?: "opaque" | "transparent"; + // 이벤트 공개 범위 (default: 기본값, public: 공개, private: 비공개, confidential: 기밀) + visibility?: "default" | "public" | "private" | "confidential"; + // iCalendar 고유 식별자 (RFC5545) + iCalUID?: string; + // 일정 순서 번호 (iCalendar 기준) + sequence?: number; + // 참석자 목록 attendees?: Array<{ - id?: string; - email: string; - displayName?: string; - organizer?: boolean; - self?: boolean; - responseStatus?: string; + id?: string; // 참석자의 Profile ID + email: string; // 참석자의 이메일 + displayName?: string; // 참석자의 표시 이름 + organizer?: boolean; // 참석자가 주최자인지 여부 + self?: boolean; // 참석자가 현재 캘린더의 소유자인지 여부 + resource?: boolean; // 참석자가 자원(예: 회의실)인지 여부 + optional?: boolean; // 선택적 참석자인지 여부 + responseStatus?: string; // 참석 응답 상태 + comment?: string; // 참석자의 코멘트 + additionalGuests?: number; // 추가 게스트 수 }>; + // 참석자 목록이 생략되었는지 여부 + attendeesOmitted?: boolean; + // 확장 속성 + extendedProperties?: { + private?: { [key: string]: string }; // 개인 속성 + shared?: { [key: string]: string }; // 공유 속성 + }; + // Google Hangout 링크 hangoutLink?: string; + // 화상 회의 데이터 conferenceData?: { + createRequest?: { + requestId?: string; + conferenceSolutionKey?: { + type?: string; + }; + status?: { + statusCode?: string; + }; + }; entryPoints?: Array<{ entryPointType?: string; uri?: string; @@ -50,10 +110,68 @@ export interface CalendarEvent { pin?: string; accessCode?: string; meetingCode?: string; + passcode?: string; + password?: string; }>; + conferenceSolution?: { + key?: { + type?: string; + }; + name?: string; + iconUri?: string; + }; conferenceId?: string; + signature?: string; + notes?: string; }; + // 가젯 정보 (deprecated) + gadget?: { + type?: string; + title?: string; + link?: string; + iconLink?: string; + width?: number; + height?: number; + display?: string; + preferences?: { [key: string]: string }; + }; + // 누구나 자신을 초대할 수 있는지 여부 + anyoneCanAddSelf?: boolean; + // 게스트가 다른 사람을 초대할 수 있는지 여부 + guestsCanInviteOthers?: boolean; + // 게스트가 이벤트를 수정할 수 있는지 여부 + guestsCanModify?: boolean; + // 게스트가 다른 게스트를 볼 수 있는지 여부 + guestsCanSeeOtherGuests?: boolean; + // 개인 복사본인지 여부 + privateCopy?: boolean; + // 수정 불가능한 이벤트인지 여부 + locked?: boolean; + // 알림 설정 + reminders?: { + useDefault?: boolean; + overrides?: Array<{ + method?: string; + minutes?: number; + }>; + }; + // 이벤트 소스 정보 + source?: { + url?: string; + title?: string; + }; + // 첨부 파일 + attachments?: Array<{ + fileUrl?: string; + title?: string; + mimeType?: string; + iconLink?: string; + fileId?: string; + }>; + // 이벤트 유형 (default: 기본, outOfOffice: 부재중, focusTime: 집중 시간, workingLocation: 근무 위치) + eventType?: "default" | "outOfOffice" | "focusTime" | "workingLocation"; } +// End of Selection export interface Note { id: string; diff --git a/apps/desktop/src/utils/time.ts b/apps/desktop/src/utils/time.ts new file mode 100644 index 0000000000..a9ce3bb69c --- /dev/null +++ b/apps/desktop/src/utils/time.ts @@ -0,0 +1,17 @@ +import type { CalendarEvent } from "../types"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; + +export const formatMeetingTime = (date: CalendarEvent["start"]) => { + if (!date) return ""; + + const time = date.dateTime + ? new Date(date.dateTime) + : date.date + ? new Date(date.date) + : null; + + if (!time) return ""; + + return format(time, "HH:mm", { locale: ko }); +};