diff --git a/package-lock.json b/package-lock.json
index af40e50..0a8295e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,19 +1,19 @@
{
"name": "dotbugi",
- "version": "2.1.6",
+ "version": "3.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dotbugi",
- "version": "2.1.6",
+ "version": "3.0.0",
"dependencies": {
"@heroui/switch": "^2.2.9",
"@heroui/system": "^2.4.7",
"@heroui/theme": "^2.4.6",
"@radix-ui/react-avatar": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.3",
- "@radix-ui/react-dialog": "^1.1.5",
+ "@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
@@ -33,6 +33,7 @@
"lucide-react": "^0.471.2",
"node-fetch": "^3.3.2",
"react": "^18.3.1",
+ "react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.3",
"tailwind-merge": "^2.6.0",
@@ -1594,25 +1595,25 @@
}
},
"node_modules/@radix-ui/react-dialog": {
- "version": "1.1.5",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.5.tgz",
- "integrity": "sha512-LaO3e5h/NOEL4OfXjxD43k9Dx+vn+8n+PCFt6uhX/BADFflllyv3WJG6rgvvSVBxpTch938Qq/LGc2MMxipXPw==",
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz",
+ "integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
- "@radix-ui/react-dismissable-layer": "1.1.4",
+ "@radix-ui/react-dismissable-layer": "1.1.5",
"@radix-ui/react-focus-guards": "1.1.1",
- "@radix-ui/react-focus-scope": "1.1.1",
+ "@radix-ui/react-focus-scope": "1.1.2",
"@radix-ui/react-id": "1.1.0",
- "@radix-ui/react-portal": "1.1.3",
+ "@radix-ui/react-portal": "1.1.4",
"@radix-ui/react-presence": "1.1.2",
- "@radix-ui/react-primitive": "2.0.1",
- "@radix-ui/react-slot": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-slot": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.2.4",
- "react-remove-scroll": "^2.6.2"
+ "react-remove-scroll": "^2.6.3"
},
"peerDependencies": {
"@types/react": "*",
@@ -1630,14 +1631,14 @@
}
},
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-dismissable-layer": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.4.tgz",
- "integrity": "sha512-XDUI0IVYVSwjMXxM6P4Dfti7AH+Y4oS/TB+sglZ/EXc7cqLwGAmp1NlMrcUjj7ks6R5WTZuWKv44FBbLpwU3sA==",
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
+ "integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
- "@radix-ui/react-primitive": "2.0.1",
+ "@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
@@ -1656,6 +1657,96 @@
}
}
},
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-focus-scope": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz",
+ "integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.1",
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-callback-ref": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-portal": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
+ "integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-primitive": "2.0.2",
+ "@radix-ui/react-use-layout-effect": "1.1.0"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
+ "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-slot": "1.1.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
+ "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/react-compose-refs": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-direction": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -6214,6 +6305,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-colorful": {
+ "version": "5.6.1",
+ "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
+ "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -6238,16 +6339,16 @@
}
},
"node_modules/react-remove-scroll": {
- "version": "2.6.2",
- "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz",
- "integrity": "sha512-KmONPx5fnlXYJQqC62Q+lwIeAk64ws/cUw6omIumRzMRPqgnYqhSSti99nbj0Ry13bv7dF+BKn7NB+OqkdZGTw==",
+ "version": "2.6.3",
+ "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz",
+ "integrity": "sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==",
"license": "MIT",
"dependencies": {
"react-remove-scroll-bar": "^2.3.7",
- "react-style-singleton": "^2.2.1",
+ "react-style-singleton": "^2.2.3",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.3",
- "use-sidecar": "^1.1.2"
+ "use-sidecar": "^1.1.3"
},
"engines": {
"node": ">=10"
diff --git a/package.json b/package.json
index b840b22..5c8aff2 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"lucide-react": "^0.471.2",
"node-fetch": "^3.3.2",
"react": "^18.3.1",
+ "react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.3",
"tailwind-merge": "^2.6.0",
diff --git a/src/content/App.tsx b/src/content/App.tsx
index c9b717c..c5d46d3 100644
--- a/src/content/App.tsx
+++ b/src/content/App.tsx
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react';
import icon from '@/assets/icon.png';
import exit from '@/assets/exit.png';
import { Assign, Filters, Quiz, TAB_TYPE, Vod } from './types';
-import { ListFilter, RefreshCw, Search } from 'lucide-react';
+import { ListFilter, OctagonAlert, RefreshCw, Search } from 'lucide-react';
import filter from '@/assets/filter.svg';
import PopoverFooter from './components/PopoverFooter';
import { Spinner } from '@/components/ui/spinner';
@@ -31,7 +31,7 @@ export default function App() {
const { courses } = useGetCourses();
// 데이터 관련 상태를 useCourseData 커스텀 훅으로 관리
- const { vods, assigns, quizes, isPending, remainingTime, refreshTime, updateData, setIsPending } =
+ const { vods, assigns, quizes, isPending, remainingTime, refreshTime, isError, updateData, setIsPending } =
useCourseData(courses);
// activeTab의 타입을 TAB_TYPE으로 지정
@@ -198,10 +198,8 @@ export default function App() {
: '오류'}
- {/* refreshTime 대신 남은 시간을 표시 */}
= 30
? 'text-amber-500 font-semibold'
@@ -216,10 +214,12 @@ export default function App() {
: `${Math.floor(remainingTime / 60)}시간 전`}
) : (
<>
- {activeTab === 'VIDEO' && }
- {activeTab === 'ASSIGN' && }
- {activeTab === 'QUIZ' && }
+ {isError ? (
+
+
+
오류가 발생했습니다.
+
{
+ location.reload();
+ }}
+ className="py-4 text-xl font-medium underline text-zinc-500 hover:text-zinc-950 hover:cursor-pointer transition-all duration-200"
+ >
+ 페이지 새로고침
+
+
+ ) : (
+ <>
+ {activeTab === 'VIDEO' && }
+ {activeTab === 'ASSIGN' && }
+ {activeTab === 'QUIZ' && }
+ >
+ )}
>
)}
diff --git a/src/hooks/useCourseData.tsx b/src/hooks/useCourseData.tsx
index 3d0bfa1..6db514f 100644
--- a/src/hooks/useCourseData.tsx
+++ b/src/hooks/useCourseData.tsx
@@ -12,10 +12,12 @@ export function useCourseData(courses: any[]) {
const [refreshTime, setRefreshTime] = useState(null);
const [isPending, setIsPending] = useState(false);
const [remainingTime, setRemainingTime] = useState(0);
+ const [isError, setIsError] = useState(false);
// updateData 함수를 useCallback으로 선언하여 useEffect 등에서 재사용
const updateData = useCallback(async () => {
try {
+ setIsError(false);
setIsPending(true);
const currentTime = new Date().getTime();
setVods([]);
@@ -98,7 +100,8 @@ export function useCourseData(courses: any[]) {
saveDataToStorage('lastRequestTime', currentTime.toString());
setIsPending(false);
} catch (error) {
- console.error('Error fetching data:', error);
+ localStorage.removeItem('lastRequestTime');
+ setIsError(true);
setIsPending(false);
}
}, [courses]);
@@ -142,5 +145,5 @@ export function useCourseData(courses: any[]) {
});
}
}, [courses, updateData]);
- return { vods, assigns, quizes, isPending, remainingTime, refreshTime, updateData, setIsPending };
+ return { vods, assigns, quizes, isPending, remainingTime, refreshTime, isError, updateData, setIsPending };
}
diff --git a/src/hooks/useGetCourse.ts b/src/hooks/useGetCourse.ts
index 38d9266..ed2199d 100644
--- a/src/hooks/useGetCourse.ts
+++ b/src/hooks/useGetCourse.ts
@@ -1,4 +1,5 @@
import { CourseBase } from '@/content/types';
+import { saveDataToStorage } from '@/lib/storage';
import { removeSquareBrackets } from '@/lib/utils';
import { useState, useEffect } from 'react';
@@ -12,27 +13,25 @@ export const useGetCourses = (): UseCouresResult => {
if (!document) return;
const courseData = Array.from(document.querySelectorAll('.course_box'));
const data = courseData
- .map((div) => {
- const label = div.querySelector('.course_link .course-name .course-label')?.textContent?.trim() || null;
- if (!label || label === '커뮤니티') return null;
- const a = div.querySelector('a');
- const url = new URL((a as HTMLAnchorElement).href);
- const urlParams = new URLSearchParams(url.search);
- const courseId = urlParams.get('id') || '';
- const titleSection = div.querySelector('.course_link .course-name .course-title');
- const prof = titleSection?.querySelector('p')?.textContent?.trim() || '';
- let courseTitle =
- titleSection?.querySelector('h1, h2, h3')?.textContent?.replace(/new/i, '').trim() ||
- '';
- courseTitle = removeSquareBrackets(courseTitle);
- return { courseId, courseTitle, prof };
- })
- .filter(
- (item): item is { courseId: string; courseTitle: string; prof: string } =>
- item !== null && item.courseId !== '' && item.courseTitle !== '' && item.prof !== ''
- );
- setCourses(data);
-
+ .map((div) => {
+ const label = div.querySelector('.course_link .course-name .course-label')?.textContent?.trim() || null;
+ if (!label || label === '커뮤니티') return null;
+ const a = div.querySelector('a');
+ const url = new URL((a as HTMLAnchorElement).href);
+ const urlParams = new URLSearchParams(url.search);
+ const courseId = urlParams.get('id') || '';
+ const titleSection = div.querySelector('.course_link .course-name .course-title');
+ const prof = titleSection?.querySelector('p')?.textContent?.trim() || '';
+ let courseTitle = titleSection?.querySelector('h1, h2, h3')?.textContent?.replace(/new/i, '').trim() || '';
+ courseTitle = removeSquareBrackets(courseTitle);
+ return { courseId, courseTitle, prof };
+ })
+ .filter(
+ (item): item is { courseId: string; courseTitle: string; prof: string } =>
+ item !== null && item.courseId !== '' && item.courseTitle !== '' && item.prof !== ''
+ );
+ setCourses(data);
+ saveDataToStorage('courses', JSON.stringify(data));
}, []);
return { courses };
diff --git a/src/lib/calendarUtils.ts b/src/lib/calendarUtils.ts
index ec2c9dd..3464504 100644
--- a/src/lib/calendarUtils.ts
+++ b/src/lib/calendarUtils.ts
@@ -13,10 +13,6 @@ export type GoogleCalendarEvent = {
};
};
-/**
- * OAuth 토큰을 localStorage에서 가져옵니다.
- * 실제 구현에서는 OAuth 플로우에 따라 토큰을 갱신하거나, Context/API를 통해 관리할 수 있습니다.
- */
export const getOAuthToken = async (): Promise => {
return new Promise((resolve) => {
chrome.identity.getAuthToken({ interactive: false }, (cachedToken) => {
diff --git a/src/option/App.tsx b/src/option/App.tsx
index a4426f4..91442e2 100644
--- a/src/option/App.tsx
+++ b/src/option/App.tsx
@@ -9,6 +9,7 @@ import DashboardPage from '@/pages/DashboardPage';
import QuizPage from '@/pages/QuizPage';
import Header from './Header';
import Labo from './Labo';
+import ColorSetting from './ColorSetting';
const pageVariants = {
initial: {
@@ -50,6 +51,7 @@ const AnimatedRoutes = () => {
} />
} />
} />
+ } />
404 Not Found} />
diff --git a/src/option/ColorSetting.tsx b/src/option/ColorSetting.tsx
new file mode 100644
index 0000000..0130555
--- /dev/null
+++ b/src/option/ColorSetting.tsx
@@ -0,0 +1,434 @@
+import { useEffect, useState } from 'react';
+import { Save, RotateCcw } from 'lucide-react';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { Input } from '@/components/ui/input';
+import { toast } from '@/hooks/use-toast';
+import { loadDataFromStorage, saveDataToStorage } from '@/lib/storage';
+import type { CourseBase } from '@/content/types';
+import { HexColorPicker } from 'react-colorful';
+
+// Update the CourseColorSetting type to include opacity
+export type CourseColorSetting = {
+ courseId: string;
+ color: string;
+ colorType: 'solid' | 'gradient';
+ gradient?: string;
+ opacity?: number; // Add opacity property
+};
+
+// Recommended color presets
+const colorPresets = [
+ '#e2e2e2', // Light Gray
+ '#ff75c3', // Pink
+ '#ffa647', // Orange
+ '#ffe83f', // Yellow
+ '#9fff5b', // Lime
+ '#70e2ff', // Cyan
+ '#cd93ff', // Purple
+ '#09203f', // Dark Blue
+];
+
+// Gradient presets
+const gradientPresets = [
+ 'linear-gradient(to right, #ff75c3, #ffa647)',
+ 'linear-gradient(to right, #ffa647, #ffe83f)',
+ 'linear-gradient(to right, #ffe83f, #9fff5b)',
+ 'linear-gradient(to right, #9fff5b, #70e2ff)',
+ 'linear-gradient(to right, #70e2ff, #cd93ff)',
+ 'linear-gradient(to right, #cd93ff, #ff75c3)',
+ 'linear-gradient(to right, #AC32E4, #4801FF)',
+ 'linear-gradient(to right, #09203f, #537895)',
+];
+
+export default function ColorSetting() {
+ const [courses, setCourses] = useState([]);
+ const [courseColors, setCourseColors] = useState([]);
+ const [originalColors, setOriginalColors] = useState>({});
+
+ useEffect(() => {
+ loadDataFromStorage('courses', (data: string | null) => {
+ if (data) {
+ const loadedCourses: CourseBase[] = JSON.parse(data);
+ setCourses(loadedCourses);
+ }
+ });
+ }, []);
+
+ useEffect(() => {
+ loadDataFromStorage('courseColors', (data: string | null) => {
+ if (data) {
+ const loadedColors: CourseColorSetting[] = JSON.parse(data);
+
+ const updatedColors = loadedColors.map((color) => ({
+ ...color,
+ colorType: color.colorType || 'solid',
+ }));
+
+ setCourseColors(updatedColors);
+
+ const originals: Record = {};
+ updatedColors.forEach((item) => {
+ originals[item.courseId] = item.color;
+ });
+ setOriginalColors(originals);
+ }
+ });
+ }, []);
+
+ useEffect(() => {
+ if (courses.length > 0 && courseColors.length === 0) {
+ const defaultColors = courses.map((course) => ({
+ courseId: course.courseId,
+ color: '#6366f1',
+ colorType: 'solid' as const,
+ }));
+ setCourseColors(defaultColors);
+
+ const originals: Record = {};
+ defaultColors.forEach((item) => {
+ originals[item.courseId] = item.color;
+ });
+ setOriginalColors(originals);
+ }
+ }, [courses]);
+
+ const handleColorChange = (
+ courseId: string,
+ newColor: string,
+ colorType: 'solid' | 'gradient' = 'solid',
+ gradientValue?: string,
+ opacity?: number
+ ) => {
+ setCourseColors((prev) =>
+ prev.map((item) => {
+ if (item.courseId === courseId) {
+ return {
+ ...item,
+ color: newColor,
+ colorType,
+ gradient: gradientValue,
+ opacity: opacity !== undefined ? opacity : item.opacity || 1, // Preserve or set default opacity
+ };
+ }
+ return item;
+ })
+ );
+ };
+ const resetToOriginal = (courseId: string) => {
+ if (originalColors[courseId]) {
+ handleColorChange(courseId, originalColors[courseId], 'solid');
+ }
+ };
+
+ // Save color settings
+ const handleSave = () => {
+ saveDataToStorage('courseColors', JSON.stringify(courseColors));
+
+ const originals: Record = {};
+ courseColors.forEach((item) => {
+ originals[item.courseId] = item.color;
+ });
+ setOriginalColors(originals);
+
+ toast({
+ title: '색상 설정 저장 완료',
+ description: '강의 색상 설정이 저장되었습니다.',
+ });
+ };
+
+ const getCourseColorSetting = (courseId: string) => {
+ return (
+ courseColors.find((item) => item.courseId === courseId) || {
+ courseId,
+ color: '#6366f1',
+ colorType: 'solid' as const,
+ }
+ );
+ };
+
+ const getOriginalColor = (courseId: string) => {
+ return originalColors[courseId] || '#6366f1';
+ };
+
+ return (
+
+
+
강의 색상 설정
+
각 강의에 표시될 색상을 설정합니다.
+
+
+
+ {courses.map((course) => (
+ resetToOriginal(course.courseId)}
+ />
+ ))}
+
+
+
+
+
+
+ );
+}
+
+interface CourseCardProps {
+ course: CourseBase;
+ colorSetting: CourseColorSetting;
+ originalColor: string;
+ onColorChange: (
+ courseId: string,
+ color: string,
+ colorType: 'solid' | 'gradient',
+ gradient?: string,
+ opacity?: number
+ ) => void;
+ onReset: () => void;
+}
+
+function CourseCard({ course, colorSetting, originalColor, onColorChange, onReset }: CourseCardProps) {
+ const hasChanged = colorSetting.color !== originalColor;
+ const [customColor, setCustomColor] = useState(colorSetting.color);
+ const [customGradient, setCustomGradient] = useState(
+ colorSetting.gradient || 'linear-gradient(to right, #ff75c3, #ffa647)'
+ );
+ const [gradientStartColor, setGradientStartColor] = useState('#ff75c3');
+ const [gradientEndColor, setGradientEndColor] = useState('#ffa647');
+ const [opacity, setOpacity] = useState(colorSetting.opacity || 1);
+
+ useEffect(() => {
+ setCustomColor(colorSetting.color);
+ setOpacity(colorSetting.opacity || 1);
+ if (colorSetting.gradient) {
+ setCustomGradient(colorSetting.gradient);
+
+ const gradientMatch = colorSetting.gradient.match(
+ /linear-gradient\(to right, (#[0-9a-fA-F]{3,6}), (#[0-9a-fA-F]{3,6})\)/
+ );
+ if (gradientMatch) {
+ setGradientStartColor(gradientMatch[1]);
+ setGradientEndColor(gradientMatch[2]);
+ }
+ }
+ }, [colorSetting]);
+
+ const getBackgroundStyle = () => {
+ const opacityValue = opacity !== undefined ? opacity : 1;
+
+ switch (colorSetting.colorType) {
+ case 'gradient':
+ if (colorSetting.gradient) {
+ const gradientMatch = colorSetting.gradient.match(
+ /linear-gradient\(to right, (#[0-9a-fA-F]{3,6}), (#[0-9a-fA-F]{3,6})\)/
+ );
+
+ if (gradientMatch) {
+ const startColor = gradientMatch[1];
+ const endColor = gradientMatch[2];
+ const startRgba = hexToRgba(startColor, opacityValue);
+ const endRgba = hexToRgba(endColor, opacityValue);
+ return { background: `linear-gradient(to right, ${startRgba}, ${endRgba})` };
+ }
+ return { background: colorSetting.gradient };
+ }
+ return { background: customGradient };
+
+ case 'solid':
+ default:
+ return { backgroundColor: hexToRgba(colorSetting.color, opacityValue) };
+ }
+ };
+
+ const hexToRgba = (hex: string, opacity: number) => {
+ hex = hex.replace('#', '');
+
+ let r, g, b;
+ if (hex.length === 3) {
+ r = Number.parseInt(hex.charAt(0) + hex.charAt(0), 16);
+ g = Number.parseInt(hex.charAt(1) + hex.charAt(1), 16);
+ b = Number.parseInt(hex.charAt(2) + hex.charAt(2), 16);
+ } else {
+ r = Number.parseInt(hex.substring(0, 2), 16);
+ g = Number.parseInt(hex.substring(2, 4), 16);
+ b = Number.parseInt(hex.substring(4, 6), 16);
+ }
+
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
+ };
+
+ const handleOpacityChange = (value: number) => {
+ setOpacity(value);
+ if (colorSetting.colorType === 'solid') {
+ onColorChange(course.courseId, customColor, 'solid', undefined, value);
+ } else {
+ onColorChange(course.courseId, customGradient, 'gradient', customGradient, value);
+ }
+ };
+
+ const handleCustomColorChange = (value: string) => {
+ setCustomColor(value);
+ onColorChange(course.courseId, value, 'solid', undefined, opacity);
+ };
+
+ const handleCustomGradientChange = (value: string) => {
+ setCustomGradient(value);
+ onColorChange(course.courseId, value, 'gradient', value, opacity);
+ };
+
+ const handleGradientColorChange = (startColor: string, endColor: string) => {
+ const newGradient = `linear-gradient(to right, ${startColor}, ${endColor})`;
+ setGradientStartColor(startColor);
+ setGradientEndColor(endColor);
+ setCustomGradient(newGradient);
+ onColorChange(course.courseId, newGradient, 'gradient', newGradient, opacity);
+ };
+
+ const opacitySlider = (
+
+
+
+ {Math.round(opacity * 100)}%
+
+
handleOpacityChange(Number.parseFloat(e.target.value))}
+ className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
+ />
+
+ );
+
+ return (
+
+
+ {course.courseTitle}
+ {course.prof}
+
+
+
+
+
+
+
+
+
+
+
+ Solid
+
+
+ Gradient
+
+
+
+
+
+ {colorPresets.map((color) => (
+
onColorChange(course.courseId, color, 'solid', undefined, opacity)}
+ />
+ ))}
+
+
+
+ handleCustomColorChange(color)}
+ className="w-full h-[120px]"
+ />
+
+
+
+ handleCustomColorChange(e.target.value)}
+ className="h-8"
+ />
+
+
+ {opacitySlider}
+
+
+
+
+ {gradientPresets.map((gradient, index) => (
+
onColorChange(course.courseId, gradient, 'gradient', gradient, opacity)}
+ />
+ ))}
+
+
+
+
+
시작 색상
+
handleGradientColorChange(color, gradientEndColor)}
+ className="w-full h-[120px]"
+ />
+
+
+
끝 색상
+
handleGradientColorChange(gradientStartColor, color)}
+ className="w-full h-[120px]"
+ />
+
+
+
+
+
+ {opacitySlider}
+
+
+
+
+
+
선택된 스타일
+
+
+ {colorSetting.colorType === 'solid'
+ ? `${colorSetting.color} (${Math.round((colorSetting.opacity || 1) * 100)}%)`
+ : `Gradient (${Math.round((colorSetting.opacity || 1) * 100)}%)`}
+
+
+
+ {hasChanged && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/option/Labo.tsx b/src/option/Labo.tsx
index 515fa87..890e173 100644
--- a/src/option/Labo.tsx
+++ b/src/option/Labo.tsx
@@ -43,6 +43,16 @@ const Labo: React.FC = () => {
});
};
+ const handleLogout = () => {
+ if (!token) return;
+
+ chrome.identity.removeCachedAuthToken({ token }, () => {
+ setToken(null);
+ setEvents([]); // 이벤트 목록 초기화
+ console.log('Google 계정 연동 해제 완료');
+ });
+ };
+
const addCalendarEvent = () => {
if (!token) return;
@@ -70,7 +80,7 @@ const Labo: React.FC = () => {
.then((response) => response.json())
.then(() => {
alert('새로운 이벤트가 추가되었습니다!');
- fetchCalendarEvents(token); // 이벤트 추가 후 다시 조회
+ fetchCalendarEvents(token);
})
.catch((error) => console.error('이벤트 추가 실패:', error));
};
@@ -83,6 +93,9 @@ const Labo: React.FC = () => {
+
내 캘린더 일정
{events.length > 0 ? (
diff --git a/src/option/Sidebar.tsx b/src/option/Sidebar.tsx
index 8ac014e..4bd1f89 100644
--- a/src/option/Sidebar.tsx
+++ b/src/option/Sidebar.tsx
@@ -1,4 +1,14 @@
-import { Calendar, Home, LayoutDashboard, NotebookText, NotepadText, Video, Zap } from 'lucide-react';
+import {
+ Calendar,
+ Home,
+ LayoutDashboard,
+ NotebookText,
+ NotepadText,
+ Palette,
+ Settings,
+ Video,
+ Zap,
+} from 'lucide-react';
import type React from 'react';
import { Link, useLocation } from 'react-router-dom';
import icon from '@/assets/icon.png';
@@ -52,6 +62,7 @@ const Sidebar: React.FC = () => {
} label="캘린더 연동" />
+ } label="색상 변경" />
diff --git a/src/option/calendar.tsx b/src/option/calendar.tsx
index 1120652..6fd8a96 100644
--- a/src/option/calendar.tsx
+++ b/src/option/calendar.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useMemo } from 'react';
+import React, { useState, useMemo, useEffect } from 'react';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -19,8 +19,6 @@ import { ChevronLeft, ChevronRight, NotebookText, Zap, ListFilter, CalendarArrow
import useCalendarEvents, { CalendarEvent } from '@/hooks/useCalendarEvents';
import filter from '@/assets/filter.svg';
import { Label } from '@/components/ui/label';
-
-// 새 이벤트 동기화 관련 유틸리티 함수 (각자 환경에 맞게 구현)
import {
getOAuthToken,
addCalendarEvent,
@@ -29,23 +27,21 @@ import {
GoogleCalendarEvent,
} from '@/lib/calendarUtils';
import { toast } from '@/hooks/use-toast';
+import { loadDataFromStorage } from '@/lib/storage';
-// 날짜 비교
+// 헬퍼 함수들
function isSameDate(d1: Date, d2: Date) {
return d1.getFullYear() === d2.getFullYear() && d1.getMonth() === d2.getMonth() && d1.getDate() === d2.getDate();
}
-// day가 이벤트 구간에 포함되는지 검사
function isInEventRange(day: Date, event: CalendarEvent) {
return day >= event.start && day <= event.end;
}
-// 이벤트가 단일 날짜인지
function isSingleDayEvent(event: CalendarEvent) {
return isSameDate(event.start, event.end);
}
-// 범위 이벤트에서 현재 day의 위치 판별
function getRangePosition(day: Date, event: CalendarEvent): 'single' | 'start' | 'middle' | 'end' | 'after' | null {
if (!isInEventRange(day, event)) return null;
if (isSingleDayEvent(event)) return 'single';
@@ -55,12 +51,19 @@ 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 속성을 추가한 타입 정의
+// 헬퍼: hex 색상을 rgba 문자열로 변환 (투명도 적용)
+function hexToRgba(hex: string, opacity: number): string {
+ hex = hex.replace('#', '');
+ const r = parseInt(hex.substring(0, 2), 16);
+ const g = parseInt(hex.substring(2, 4), 16);
+ const b = parseInt(hex.substring(4, 6), 16);
+ return `rgba(${r}, ${g}, ${b}, ${opacity})`;
+}
+
interface CalendarEventWithRow extends CalendarEvent {
row: number;
}
@@ -81,28 +84,23 @@ export function Calendar() {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState(null);
- // 필터 state 관리 (체크된 필터는 문자열 배열로 관리)
+ // 필터 state 관리
const [selectedFilters, setSelectedFilters] = useState([]);
- // Popover 열림/닫힘 상태
const [isFilterOpen, setIsFilterOpen] = useState(false);
- // type별 기본 필터 항목
const typeFilters = [
{ value: 'vod', label: '동영상 강의' },
{ value: 'assign', label: '과제' },
{ value: 'quiz', label: '퀴즈' },
];
- // 이벤트의 title들을 중복 없이 필터 항목에 추가
const titleFilters = Array.from(new Set(events.map((event) => event.title))).map((title) => ({
value: title,
label: title,
}));
- // type 필터와 title 필터를 합치기 (중복 없이)
const filterOptions = [...typeFilters, ...titleFilters];
- // 토글 함수: 선택한 필터가 있으면 제거, 없으면 추가
const toggleFilter = (filter: string) => {
setSelectedFilters((prev) => (prev.includes(filter) ? prev.filter((f) => f !== filter) : [...prev, filter]));
};
@@ -111,10 +109,8 @@ export function Calendar() {
setSelectedFilters([]);
};
- // 필터가 하나라도 설정되어 있는지 (아이콘 표시 용도)
const isFilterSet = selectedFilters.length > 0;
- // 달력 관련 계산
const firstDayOfMonth = startOfMonth(currentMonth);
const startDate = startOfWeek(firstDayOfMonth, { weekStartsOn: 0 });
const dayList = useMemo(() => {
@@ -142,14 +138,11 @@ export function Calendar() {
}
};
- // ── 이벤트에 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++;
}
@@ -158,7 +151,6 @@ export function Calendar() {
return assigned;
}, [events]);
- // subject 별 색상 매핑 (이전과 동일하게 event.title 기준)
const subjectList = useMemo(() => {
return Array.from(new Set(events.map((event) => event.title)));
}, [events]);
@@ -171,13 +163,38 @@ export function Calendar() {
return map;
}, [subjectList]);
- // 날짜에 해당하는 이벤트들을 row 번호 기준으로 렌더링
+ // 저장소에서 강의 색상 데이터 불러오기 (vod 데이터에 적용)
+ const [courseColors, setCourseColors] = useState<{
+ [courseId: string]: { color: string; colorType: string; gradient?: string; opacity: number };
+ } | null>(null);
+
+ useEffect(() => {
+ loadDataFromStorage('courseColors', (data: string | null) => {
+ if (data) {
+ try {
+ const parsedData = JSON.parse(data);
+ const map = Array.isArray(parsedData)
+ ? parsedData.reduce(
+ (acc, cur) => {
+ acc[cur.courseId] = cur;
+ return acc;
+ },
+ {} as { [courseId: string]: { color: string; colorType: string; gradient?: string; opacity: number } }
+ )
+ : {};
+ setCourseColors(map);
+ } catch (error) {
+ console.error('courseColors 파싱 에러:', error);
+ }
+ }
+ });
+ }, []);
+
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 = eventsWithRow.filter((event) => {
if (!isInEventRange(day, event)) return false;
if (selectedTypeFilters.length > 0 && selectedTitleFilters.length > 0) {
@@ -190,18 +207,45 @@ export function Calendar() {
return true;
});
- // 해당 날짜에서 할당된 row 최대값 (없으면 최소 0행)
const maxRow = eventsOfTheDay.length > 0 ? Math.max(...eventsOfTheDay.map((e) => e.row)) : -1;
const numRows = maxRow + 1;
return (
{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
;
+
+ const eventId = event.id.split('-')[0];
+ const isVodCustom = event.type === 'vod' && courseColors && eventId && courseColors[eventId];
+ let customStyle = {};
+
+ if (isVodCustom) {
+ const courseData = courseColors![eventId];
+ const totalDays = Math.floor((event.end.getTime() - event.start.getTime()) / (1000 * 3600 * 24)) + 1;
+ if (courseData.colorType === 'gradient' && courseData.gradient && totalDays > 1) {
+ const dayIndex = Math.floor((day.getTime() - event.start.getTime()) / (1000 * 3600 * 24));
+ const regex = /linear-gradient\(to right, (#[0-9a-fA-F]+), (#[0-9a-fA-F]+)\)/;
+ const match = courseData.gradient.match(regex);
+ if (match) {
+ const rgba1 = hexToRgba(match[1], courseData.opacity);
+ const rgba2 = hexToRgba(match[2], courseData.opacity);
+ customStyle = {
+ backgroundImage: `linear-gradient(to right, ${rgba1}, ${rgba2})`,
+ backgroundSize: `${totalDays * 100}% 100%`,
+ backgroundPosition: `${-(dayIndex * 100)}% 0`,
+ };
+ } else {
+ customStyle = { backgroundImage: courseData.gradient, opacity: courseData.opacity };
+ }
+ } else {
+ // solid인 경우: hex 색상에 opacity 반영
+ customStyle = { background: hexToRgba(courseData.color, courseData.opacity) };
+ }
+ }
+
if (rangePosition === 'single') {
return (
@@ -218,18 +262,24 @@ export function Calendar() {
);
}
+
const isStart = rangePosition === 'start';
const isEnd = rangePosition === 'end';
const showTitle = isStart;
+
+ const additionalClasses = cn(isStart && 'rounded-l-sm ml-1', isEnd && 'rounded-r-sm mr-1');
+
+ const defaultClass = isVodCustom
+ ? ''
+ : isCurrent
+ ? `${subjectColorMap[event.title]} text-zinc-700`
+ : 'bg-zinc-100 text-zinc-300';
+
return (
{showTitle && (
@@ -239,8 +289,7 @@ export function Calendar() {
);
} else {
- // 해당 row에 이벤트가 없으면 빈 자리
- return
;
+ return
;
}
})}
@@ -260,7 +309,6 @@ export function Calendar() {
try {
const existingEvents: GoogleCalendarEvent[] = await getCalendarEvents(token);
-
const newEventsData: GoogleCalendarEvent[] = convertCalendarEventsToGoogleEvents(events);
const normalizeEvent = (event: {
@@ -424,7 +472,6 @@ export function Calendar() {
className={cn(
'relative cursor-pointer w-full min-h-[120px] py-2 rounded-lg',
isCurrent ? 'bg-white' : 'text-gray-300',
- isTodayDate && 'text-blue-600 font-semibold',
isSelected && 'bg-zinc-50',
'hover:bg-zinc-50 transition-all duration-300'
)}
@@ -436,7 +483,13 @@ export function Calendar() {
)}
/>
{format(dayItem, 'd')}