From 2992a303b74adf6e50c390392273ef5229625b4e Mon Sep 17 00:00:00 2001 From: RAVIT Date: Sat, 3 Jan 2026 00:24:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Google=20Calendar=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Google OAuth 2.0 로그인 구현 - Calendar API 연동하여 이벤트 생성 기능 추가 - 시작/종료 시간 기본값 자동 설정 (현재 시간, 1시간 후) - TypeScript 타입 안전성 개선 (any 타입 제거) - Next.js 16.1.1로 업그레이드 (SSR 호환성 개선) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- app/calendar/page.tsx | 345 ++++++++++++++++++++++++++++++++++++++++++ app/global-error.tsx | 2 +- package-lock.json | 150 +++++++++--------- package.json | 6 +- tsconfig.json | 24 ++- 5 files changed, 439 insertions(+), 88 deletions(-) create mode 100644 app/calendar/page.tsx diff --git a/app/calendar/page.tsx b/app/calendar/page.tsx new file mode 100644 index 0000000..6432119 --- /dev/null +++ b/app/calendar/page.tsx @@ -0,0 +1,345 @@ +'use client' + +import { useState, useEffect } from 'react' +import Script from 'next/script' + +interface GoogleOAuthResponse { + access_token: string +} + +interface GoogleTokenClient { + requestAccessToken: () => void +} + +interface GoogleTokenClientConfig { + client_id: string + scope: string + callback: (response: GoogleOAuthResponse) => void +} + +interface CalendarEventResponse { + id: string + summary: string + htmlLink: string + startDateTime: string + endDateTime: string +} + +declare global { + interface Window { + google?: { + accounts: { + oauth2: { + initTokenClient: (config: GoogleTokenClientConfig) => GoogleTokenClient + } + } + } + } +} + +export default function CalendarPage() { + const [accessToken, setAccessToken] = useState(null) + + // 기본값: 현재 시간과 1시간 후 + const getDefaultDateTime = () => { + const now = new Date() + const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000) + + const formatDateTime = (date: Date) => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + const hours = String(date.getHours()).padStart(2, '0') + const minutes = String(date.getMinutes()).padStart(2, '0') + return `${year}-${month}-${day}T${hours}:${minutes}` + } + + return { + start: formatDateTime(now), + end: formatDateTime(oneHourLater) + } + } + + const defaultTimes = getDefaultDateTime() + + const [formData, setFormData] = useState({ + summary: '', + description: '', + location: '', + startDateTime: defaultTimes.start, + endDateTime: defaultTimes.end, + calendarId: 'primary', + }) + const [loading, setLoading] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + const [googleLoaded, setGoogleLoaded] = useState(false) + + useEffect(() => { + if (googleLoaded && window.google) { + const client = window.google.accounts.oauth2.initTokenClient({ + client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '', + scope: 'https://www.googleapis.com/auth/calendar', + callback: (response: GoogleOAuthResponse) => { + if (response.access_token) { + setAccessToken(response.access_token) + setError(null) + } + }, + }) + + const button = document.getElementById('google-signin-button') + if (button) { + button.onclick = () => { + client.requestAccessToken() + } + } + } + }, [googleLoaded]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!accessToken) { + setError('먼저 Google 로그인이 필요합니다') + return + } + + setLoading(true) + setError(null) + setResult(null) + + try { + const apiUrl = process.env.NEXT_PUBLIC_STASH_API_URL + const response = await fetch(`${apiUrl}/api/calendar/events`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + summary: formData.summary, + description: formData.description || null, + location: formData.location || null, + startDateTime: formData.startDateTime, + endDateTime: formData.endDateTime, + timeZone: 'Asia/Seoul', + attendees: [], + calendarId: formData.calendarId || null, + }), + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.message || '캘린더 이벤트 생성 실패') + } + + const data = await response.json() + setResult(data) + // 폼 초기화 (새로운 기본값으로) + const newDefaultTimes = getDefaultDateTime() + setFormData({ + summary: '', + description: '', + location: '', + startDateTime: newDefaultTimes.start, + endDateTime: newDefaultTimes.end, + calendarId: 'primary', + }) + } catch (err) { + setError(err instanceof Error ? err.message : '오류가 발생했습니다') + } finally { + setLoading(false) + } + } + + return ( + <> +