diff --git a/manifest.config.ts b/manifest.config.ts index d7413a8..7e4be55 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -28,8 +28,12 @@ const manifest = { }, ], options_page: '/option.html', - permissions: ['storage', 'notifications', 'alarms'], + permissions: ['storage', 'notifications', 'alarms', 'identity'], host_permissions: ['https://*/*', 'http://*/*'], + oauth2: { + client_id: '804067218183-3pev3tppten6i94lrfvmk729hmbdejqb.apps.googleusercontent.com', + scopes: ['https://www.googleapis.com/auth/calendar.events'], + }, } as ManifestV3Export; export default manifest; diff --git a/public/fonts/NotoSansKR-Black.ttf b/public/fonts/NotoSansKR-Black.ttf deleted file mode 100644 index 3e0ac2d..0000000 Binary files a/public/fonts/NotoSansKR-Black.ttf and /dev/null differ diff --git a/public/fonts/NotoSansKR-Bold.ttf b/public/fonts/NotoSansKR-Bold.ttf deleted file mode 100644 index 6cf639e..0000000 Binary files a/public/fonts/NotoSansKR-Bold.ttf and /dev/null differ diff --git a/public/fonts/NotoSansKR-ExtraBold.ttf b/public/fonts/NotoSansKR-ExtraBold.ttf deleted file mode 100644 index a2e47fd..0000000 Binary files a/public/fonts/NotoSansKR-ExtraBold.ttf and /dev/null differ diff --git a/public/fonts/NotoSansKR-ExtraLight.ttf b/public/fonts/NotoSansKR-ExtraLight.ttf deleted file mode 100644 index a0ef172..0000000 Binary files a/public/fonts/NotoSansKR-ExtraLight.ttf and /dev/null differ diff --git a/public/fonts/NotoSansKR-Light.ttf b/public/fonts/NotoSansKR-Light.ttf deleted file mode 100644 index db0a223..0000000 Binary files a/public/fonts/NotoSansKR-Light.ttf and /dev/null differ diff --git a/public/fonts/NotoSansKR-Medium.ttf b/public/fonts/NotoSansKR-Medium.ttf deleted file mode 100644 index 5311c8a..0000000 Binary files a/public/fonts/NotoSansKR-Medium.ttf and /dev/null differ diff --git a/public/fonts/NotoSansKR-Regular.ttf b/public/fonts/NotoSansKR-Regular.ttf deleted file mode 100644 index 1b14d32..0000000 Binary files a/public/fonts/NotoSansKR-Regular.ttf and /dev/null differ diff --git a/public/fonts/NotoSansKR-SemiBold.ttf b/public/fonts/NotoSansKR-SemiBold.ttf deleted file mode 100644 index 616bb09..0000000 Binary files a/public/fonts/NotoSansKR-SemiBold.ttf and /dev/null differ diff --git a/public/fonts/NotoSansKR-Thin.ttf b/public/fonts/NotoSansKR-Thin.ttf deleted file mode 100644 index 73ec7bc..0000000 Binary files a/public/fonts/NotoSansKR-Thin.ttf and /dev/null differ diff --git a/src/lib/calendarUtils.ts b/src/lib/calendarUtils.ts new file mode 100644 index 0000000..ec2c9dd --- /dev/null +++ b/src/lib/calendarUtils.ts @@ -0,0 +1,100 @@ +import { CalendarEvent } from '@/hooks/useCalendarEvents'; + +export type GoogleCalendarEvent = { + summary: string; + description?: string; + start: { + dateTime: string; + timeZone: string; + }; + end: { + dateTime: string; + timeZone: string; + }; +}; + +/** + * OAuth 토큰을 localStorage에서 가져옵니다. + * 실제 구현에서는 OAuth 플로우에 따라 토큰을 갱신하거나, Context/API를 통해 관리할 수 있습니다. + */ +export const getOAuthToken = async (): Promise => { + return new Promise((resolve) => { + chrome.identity.getAuthToken({ interactive: false }, (cachedToken) => { + if (chrome.runtime.lastError || !cachedToken) { + console.error('자동 로그인 실패:', chrome.runtime.lastError?.message); + resolve(null); + } else { + resolve(cachedToken); + } + }); + }); +}; + +/** + * 캘린더 API에 이벤트를 추가합니다. + * @param event 캘린더에 추가할 이벤트 객체 + * @param token OAuth 토큰 + */ +export async function addCalendarEvent(event: GoogleCalendarEvent, token: string): Promise { + try { + fetch('https://www.googleapis.com/calendar/v3/calendars/primary/events', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(event), + }) + .then((response) => response.json()) + .then(() => {}) + .catch((error) => console.error('이벤트 추가 실패:', error)); + } catch (error) { + console.error('Error adding calendar event:', error); + } +} + +/** + * 구글 캘린더 API를 사용해 현재 이벤트 목록을 가져옵니다. + * @param token OAuth 토큰 + * @returns CalendarEvent 배열 + */ +export async function getCalendarEvents(token: string): Promise { + try { + const pastDate = new Date(); + pastDate.setMonth(pastDate.getMonth() - 3); + const response = await fetch( + `https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=${pastDate.toISOString()}&orderBy=startTime&singleEvents=true`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + const data = await response.json(); + return data.items || []; + } catch (error) { + console.error('캘린더 이벤트 가져오기 실패:', error); + return []; + } +} + +/** + * CalendarEvent 배열을 받아 GoogleCalendarEvent 배열로 변환합니다. + * 여기서는 title과 subject를 summary로 조합하는 예시입니다. + * @param events CalendarEvent 배열 + * @returns GoogleCalendarEvent 배열 + */ +export function convertCalendarEventsToGoogleEvents(events: CalendarEvent[]): GoogleCalendarEvent[] { + return events.map((event) => ({ + summary: `${event.title}`, + description: `${event.subject}`, + start: { + dateTime: event.start.toISOString(), + timeZone: 'Asia/Seoul', + }, + end: { + dateTime: event.end.toISOString(), + timeZone: 'Asia/Seoul', + }, + })); +} diff --git a/src/option/App.tsx b/src/option/App.tsx index 2aedbb8..a4426f4 100644 --- a/src/option/App.tsx +++ b/src/option/App.tsx @@ -8,6 +8,7 @@ import AssignmentPage from 'src/pages/AssignmentPage'; import DashboardPage from '@/pages/DashboardPage'; import QuizPage from '@/pages/QuizPage'; import Header from './Header'; +import Labo from './Labo'; const pageVariants = { initial: { @@ -48,6 +49,7 @@ const AnimatedRoutes = () => { } /> } /> } /> + } /> 404 Not Found} /> diff --git a/src/option/Labo.tsx b/src/option/Labo.tsx new file mode 100644 index 0000000..515fa87 --- /dev/null +++ b/src/option/Labo.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; + +const Labo: React.FC = () => { + const [token, setToken] = useState(null); + const [events, setEvents] = useState([]); + + const fetchCalendarEvents = (token: string) => { + fetch( + 'https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=' + + new Date().toISOString() + + '&orderBy=startTime&singleEvents=true', + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ) + .then((response) => response.json()) + .then((data) => setEvents(data.items || [])) + .catch((error) => console.error('캘린더 이벤트 가져오기 실패:', error)); + }; + + useEffect(() => { + chrome.identity.getAuthToken({ interactive: false }, (cachedToken) => { + if (chrome.runtime.lastError || !cachedToken) { + console.error('자동 로그인 실패:', chrome.runtime.lastError?.message); + } else { + setToken(cachedToken); + fetchCalendarEvents(cachedToken); + } + }); + }, []); + + const handleLogin = () => { + chrome.identity.getAuthToken({ interactive: true }, (newToken) => { + if (chrome.runtime.lastError || !newToken) { + console.error(chrome.runtime.lastError); + return; + } + setToken(newToken); + fetchCalendarEvents(newToken); + }); + }; + + const addCalendarEvent = () => { + if (!token) return; + + const event = { + summary: '테스트 이벤트 🎉', + description: '이것은 Google Calendar API를 사용한 이벤트 생성입니다.', + start: { + dateTime: new Date().toISOString(), + timeZone: 'Asia/Seoul', + }, + end: { + dateTime: new Date(new Date().getTime() + 60 * 60 * 1000).toISOString(), + timeZone: 'Asia/Seoul', + }, + }; + + fetch('https://www.googleapis.com/calendar/v3/calendars/primary/events', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(event), + }) + .then((response) => response.json()) + .then(() => { + alert('새로운 이벤트가 추가되었습니다!'); + fetchCalendarEvents(token); // 이벤트 추가 후 다시 조회 + }) + .catch((error) => console.error('이벤트 추가 실패:', error)); + }; + + return ( +
+

Google Calendar 연동

+ {token ? ( +
+ +

내 캘린더 일정

+
    + {events.length > 0 ? ( + events.map((event, index) => ( +
  • + {event.summary} +

    + {event.start?.dateTime?.replace('T', ' ').substring(0, 16)} ~{' '} + {event.end?.dateTime?.replace('T', ' ').substring(0, 16)} +

    +
  • + )) + ) : ( +

    일정이 없습니다.

    + )} +
+
+ ) : ( + + )} +
+ ); +}; + +export default Labo; diff --git a/src/option/Sidebar.tsx b/src/option/Sidebar.tsx index 521c924..8ac014e 100644 --- a/src/option/Sidebar.tsx +++ b/src/option/Sidebar.tsx @@ -1,4 +1,4 @@ -import { Home, LayoutDashboard, NotebookText, NotepadText, Video, Zap } from 'lucide-react'; +import { Calendar, Home, LayoutDashboard, NotebookText, NotepadText, Video, Zap } from 'lucide-react'; import type React from 'react'; import { Link, useLocation } from 'react-router-dom'; import icon from '@/assets/icon.png'; @@ -28,17 +28,31 @@ const Sidebar: React.FC = () => { diff --git a/src/option/calendar.tsx b/src/option/calendar.tsx index c35fc00..1120652 100644 --- a/src/option/calendar.tsx +++ b/src/option/calendar.tsx @@ -15,13 +15,22 @@ import { addDays, isSunday, } from 'date-fns'; -import { ChevronLeft, ChevronRight, NotebookText, Zap, ListFilter } from 'lucide-react'; +import { ChevronLeft, ChevronRight, NotebookText, Zap, ListFilter, CalendarArrowUp } from 'lucide-react'; import useCalendarEvents, { CalendarEvent } from '@/hooks/useCalendarEvents'; import filter from '@/assets/filter.svg'; -import FilterItem from '@/content/components/FilterItem'; import { Label } from '@/components/ui/label'; -// 날짜가 동일한지 비교 +// 새 이벤트 동기화 관련 유틸리티 함수 (각자 환경에 맞게 구현) +import { + getOAuthToken, + addCalendarEvent, + getCalendarEvents, + convertCalendarEventsToGoogleEvents, + GoogleCalendarEvent, +} from '@/lib/calendarUtils'; +import { toast } from '@/hooks/use-toast'; + +// 날짜 비교 function isSameDate(d1: Date, d2: Date) { return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth() && d1.getDate() === d2.getDate(); } @@ -46,6 +55,16 @@ function getRangePosition(day: Date, event: CalendarEvent): 'single' | 'start' | return 'middle'; } +// 이벤트 간 겹침 여부 (두 이벤트의 기간이 겹치면 true) +function eventsOverlap(a: CalendarEvent, b: CalendarEvent) { + return a.start <= b.end && b.start <= a.end; +} + +// row 배정을 위해 CalendarEvent에 row 속성을 추가한 타입 정의 +interface CalendarEventWithRow extends CalendarEvent { + row: number; +} + const colorClasses = [ 'bg-rose-100 text-rose-800', 'bg-amber-100 text-amber-800', @@ -123,7 +142,23 @@ export function Calendar() { } }; - // 모든 이벤트에서 고유한 과목(여기서는 event.title)을 추출해 고정 순서와 색상을 부여 + // ── 이벤트에 row 번호를 할당 (겹치는 이벤트는 다른 row에 배치) ── + const eventsWithRow = useMemo(() => { + // 날짜 기준 오름차순 정렬 + const sorted = [...events].sort((a, b) => a.start.getTime() - b.start.getTime()); + const assigned: CalendarEventWithRow[] = []; + for (const event of sorted) { + let row = 0; + // 이미 배정된 이벤트 중 같은 row에 있으며 겹치는 이벤트가 있으면 row 증가 + while (assigned.some((e) => e.row === row && eventsOverlap(e, event))) { + row++; + } + assigned.push({ ...event, row }); + } + return assigned; + }, [events]); + + // subject 별 색상 매핑 (이전과 동일하게 event.title 기준) const subjectList = useMemo(() => { return Array.from(new Set(events.map((event) => event.title))); }, [events]); @@ -136,13 +171,14 @@ export function Calendar() { return map; }, [subjectList]); - // 날짜에 해당하는 이벤트들을 필터 + // 날짜에 해당하는 이벤트들을 row 번호 기준으로 렌더링 const renderEvents = (day: Date, isCurrent: boolean) => { const typeFilterValues = typeFilters.map((f) => f.value); const selectedTypeFilters = selectedFilters.filter((f) => typeFilterValues.includes(f)); const selectedTitleFilters = selectedFilters.filter((f) => !typeFilterValues.includes(f)); - const eventsOfTheDay = events.filter((event) => { + // 필터 적용 + const eventsOfTheDay = eventsWithRow.filter((event) => { if (!isInEventRange(day, event)) return false; if (selectedTypeFilters.length > 0 && selectedTitleFilters.length > 0) { return selectedTypeFilters.includes(event.type) && selectedTitleFilters.includes(event.title); @@ -154,24 +190,27 @@ export function Calendar() { return true; }); - // 렌더링할 행의 수는 subjectList의 개수로 고정 + // 해당 날짜에서 할당된 row 최대값 (없으면 최소 0행) + const maxRow = eventsOfTheDay.length > 0 ? Math.max(...eventsOfTheDay.map((e) => e.row)) : -1; + const numRows = maxRow + 1; + return (
- {subjectList.map((subject) => { - // 해당 과목의 이벤트가 오늘 포함되는지 체크 (여러 이벤트가 있다면 첫 번째만 사용) - const event = eventsOfTheDay.find((e) => e.title === subject); + {Array.from({ length: numRows }, (_, rowIndex) => { + // 해당 row에 있는 이벤트 찾기 + const event = eventsOfTheDay.find((e) => e.row === rowIndex); if (event) { const rangePosition = getRangePosition(day, event); - if (!rangePosition) return
; + if (!rangePosition) return
; if (rangePosition === 'single') { return ( -
+
{event.type === 'assign' ? ( - ) : ( + ) : event.type === 'quiz' ? ( - )} + ) : null} {event.title} - {event.subject} @@ -184,9 +223,9 @@ export function Calendar() { const showTitle = isStart; return (
); } else { - // 해당 과목의 이벤트가 없으면 빈 자리로 남겨 고정된 높이를 유지 - return
; + // 해당 row에 이벤트가 없으면 빈 자리 + return
; } })}
); }; + const handleCalendarSync = async () => { + const token = await getOAuthToken(); + if (!token) { + toast({ + title: '동기화 실패 🚨', + description: '구글 캘린더 연동 상태를 확인해주세요', + variant: 'destructive', + }); + return; + } + + try { + const existingEvents: GoogleCalendarEvent[] = await getCalendarEvents(token); + + const newEventsData: GoogleCalendarEvent[] = convertCalendarEventsToGoogleEvents(events); + + const normalizeEvent = (event: { + summary: string; + description?: string; + start: { dateTime: string }; + end: { dateTime: string }; + }) => ({ + summary: event.summary.trim().toLowerCase(), + description: (event.description || '').trim().toLowerCase(), + startTime: new Date(event.start.dateTime).getTime(), + endTime: new Date(event.end.dateTime).getTime(), + }); + + const uniqueNewEvents = newEventsData.filter((newEvent) => { + const normNew = normalizeEvent(newEvent); + return !existingEvents.some((existingEvent) => { + const normExisting = normalizeEvent(existingEvent); + return ( + normExisting.summary === normNew.summary && + normExisting.description === normNew.description && + normExisting.startTime === normNew.startTime && + normExisting.endTime === normNew.endTime + ); + }); + }); + + if (uniqueNewEvents.length === 0) { + toast({ + title: '캘린더가 최신 상태입니다 🤩', + description: '이미 최신 정보로 동기화되었습니다.', + variant: 'default', + }); + } else { + for (const event of uniqueNewEvents) { + await addCalendarEvent(event, token); + } + toast({ + title: '동기화 성공 🚀', + description: `${uniqueNewEvents.length}개의 이벤트가 추가되었습니다.`, + variant: 'default', + }); + } + } catch (error) { + toast({ + title: '동기화 오류 🚨', + description: '이벤트 동기화 중 오류가 발생했습니다.', + variant: 'destructive', + }); + console.error(error); + } + }; + return (
@@ -222,66 +328,76 @@ export function Calendar() {
-
- - - - - -
- {filterOptions.map((option) => ( -
-
- +
+ +
+
+ + + + + +
+ {filterOptions.map((option) => ( +
+
+ toggleFilter(option.value)} + className="shadow-md rounded-sm peer h-5 w-5 cursor-pointer appearance-none border border-zinc-800 bg-white checked:border-primary checked:bg-primary focus:outline-none focus:ring-primary focus:ring-offset-0" + /> + + + +
+
- -
- ))} -
- - - - + ))} +
+ + + + +
diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 2418497..4848e15 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { Calendar } from '@/option/calendar'; +import { Calendar } from '@/option/Calendar'; import SummaryCard from '@/option/SummaryCard'; export default function DashboardPage() { return ( diff --git a/src/styles/fonts.css b/src/styles/fonts.css deleted file mode 100644 index 2f8fd36..0000000 --- a/src/styles/fonts.css +++ /dev/null @@ -1,39 +0,0 @@ -@font-face { - font-family: 'Noto Sans KR'; - src: url('/fonts/NotoSansKR-Thin.ttf') format('truetype'); - font-weight: 100; - font-style: normal; -} - -@font-face { - font-family: 'Noto Sans KR'; - src: url('/fonts/NotoSansKR-Light.ttf') format('truetype'); - font-weight: 300; - font-style: normal; -} - -@font-face { - font-family: 'Noto Sans KR'; - src: url('/fonts/NotoSansKR-Regular.ttf') format('truetype'); - font-weight: 400; - font-style: normal; -} - -@font-face { - font-family: 'Noto Sans KR'; - src: url('/fonts/NotoSansKR-SemiBold.ttf') format('truetype'); - font-weight: 600; - font-style: normal; -} - -@font-face { - font-family: 'Noto Sans KR'; - src: url('/fonts/NotoSansKR-Bold.ttf') format('truetype'); - font-weight: 700; - font-style: normal; -} - -html, -body { - font-family: 'Noto Sans KR', sans-serif; -} diff --git a/src/styles/option.css b/src/styles/option.css index 23e3afe..dc00c41 100644 --- a/src/styles/option.css +++ b/src/styles/option.css @@ -1,5 +1,3 @@ -@import url('./fonts.css'); - @tailwind base; @tailwind components; @tailwind utilities; diff --git a/src/styles/shadow.css b/src/styles/shadow.css index c79352f..2e0a37d 100644 --- a/src/styles/shadow.css +++ b/src/styles/shadow.css @@ -1,5 +1,3 @@ -@import url('./fonts.css'); - @tailwind base; @tailwind components; @tailwind utilities; @@ -12,6 +10,7 @@ @layer base { /* ====== 전역 스타일이었던 부분을 모두 :host로 치환 ====== */ :host { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; font-weight: 400;