diff --git a/.gitignore b/.gitignore index 1b4801a..d649445 100755 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ Thumbs.db # 기타 임시 파일 tmp/ temp/ + +*.zip +dist \ No newline at end of file diff --git a/dist.zip b/dist.zip deleted file mode 100644 index c58626f..0000000 Binary files a/dist.zip and /dev/null differ diff --git a/manifest.config.ts b/manifest.config.ts index 0f94da1..34f7e69 100644 --- a/manifest.config.ts +++ b/manifest.config.ts @@ -27,7 +27,7 @@ const manifest = { matches: ['*://*/*'], }, ], - options_page: '/option.html', + options_page: 'option.html', permissions: ['storage', 'notifications', 'alarms', 'identity'], host_permissions: ['https://*/*', 'http://*/*'], oauth2: { diff --git a/package-lock.json b/package-lock.json index c055c04..34d37af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "dotbugi", - "version": "3.1.17", + "version": "3.1.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dotbugi", - "version": "3.1.17", + "version": "3.1.19", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@heroui/switch": "^2.2.9", "@heroui/system": "^2.4.7", "@heroui/theme": "^2.4.6", @@ -33,6 +36,7 @@ "glob": "^11.0.1", "googleapis": "^160.0.0", "lucide-react": "^0.471.2", + "motion": "^12.23.24", "node-fetch": "^3.3.2", "react": "^18.3.1", "react-colorful": "^5.6.1", @@ -436,6 +440,73 @@ "fsevents": "~2.3.2" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", @@ -5435,13 +5506,13 @@ } }, "node_modules/framer-motion": { - "version": "12.0.6", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.0.6.tgz", - "integrity": "sha512-LmrXbXF6Vv5WCNmb+O/zn891VPZrH7XbsZgRLBROw6kFiP+iTK49gxTv2Ur3F0Tbw6+sy9BVtSqnWfMUpH+6nA==", + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", "license": "MIT", "dependencies": { - "motion-dom": "^12.0.0", - "motion-utils": "^12.0.0", + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", "tslib": "^2.4.0" }, "peerDependencies": { @@ -6389,19 +6460,45 @@ "ufo": "^1.5.4" } }, + "node_modules/motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz", + "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.23.24", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/motion-dom": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.0.0.tgz", - "integrity": "sha512-CvYd15OeIR6kHgMdonCc1ihsaUG4MYh/wrkz8gZ3hBX/uamyZCXN9S9qJoYF03GqfTt7thTV/dxnHYX4+55vDg==", + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", "license": "MIT", "dependencies": { - "motion-utils": "^12.0.0" + "motion-utils": "^12.23.6" } }, "node_modules/motion-utils": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz", - "integrity": "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==", + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==", "license": "MIT" }, "node_modules/ms": { diff --git a/package.json b/package.json index 1cd8375..c89eadb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", "@heroui/switch": "^2.2.9", "@heroui/system": "^2.4.7", "@heroui/theme": "^2.4.6", @@ -35,6 +38,7 @@ "glob": "^11.0.1", "googleapis": "^160.0.0", "lucide-react": "^0.471.2", + "motion": "^12.23.24", "node-fetch": "^3.3.2", "react": "^18.3.1", "react-colorful": "^5.6.1", diff --git a/src/components/ui/border-trail.tsx b/src/components/ui/border-trail.tsx new file mode 100644 index 0000000..634b3dc --- /dev/null +++ b/src/components/ui/border-trail.tsx @@ -0,0 +1,43 @@ +'use client'; +import { cn } from '@/lib/utils'; +import { motion, Transition } from 'motion/react'; + +export type BorderTrailProps = { + className?: string; + size?: number; + transition?: Transition; + onAnimationComplete?: () => void; + style?: React.CSSProperties; +}; + +export function BorderTrail({ + className, + size = 60, + transition, + onAnimationComplete, + style, +}: BorderTrailProps) { + const defaultTransition: Transition = { + repeat: Infinity, + duration: 5, + ease: 'linear', + }; + + return ( +
+ +
+ ); +} diff --git a/src/content/App.tsx b/src/content/App.tsx index 9989bc1..97371a9 100644 --- a/src/content/App.tsx +++ b/src/content/App.tsx @@ -15,17 +15,16 @@ import { Button } from '@/components/ui/button'; import FilterBadge from './components/FilterBadge'; import FilterPanel from './components/FilterPanel'; -import { useCourseData } from '@/hooks/useCourseData'; import { filterVods, filterAssigns, filterQuizes } from '@/lib/filterData'; import PendingDialogWithBeforeUnload from './components/PendingDialog'; import StickyPopoverTrigger from './StickyPopoverTrigger'; +import { useCourseData } from '@/hooks/useCourseData'; -// 리팩토링: 필터 옵션 추출 -const attendanceOptions = ['출석', '결석']; // string[] +const attendanceOptions = ['출석', '결석']; const submitOptions = [ { label: '제출완료', value: true }, { label: '제출필요', value: false }, -]; // { label: string, value: boolean }[] +]; export default function App() { const { courses } = useGetCourses(); diff --git a/src/content/components/PendingDialog.tsx b/src/content/components/PendingDialog.tsx index 007cf40..4a2f43f 100644 --- a/src/content/components/PendingDialog.tsx +++ b/src/content/components/PendingDialog.tsx @@ -27,9 +27,11 @@ export default function PendingDialog({ isPending, onClose }: PendingDialogProps document.body.prepend(host); } setHostElement(host); - const newShadowRoot = createShadowRoot(host, [ - styles, - ` + const newShadowRoot = createShadowRoot( + host, + [ + styles, + ` :host { position: fixed; top: 0; @@ -42,7 +44,9 @@ export default function PendingDialog({ isPending, onClose }: PendingDialogProps z-index: 9999; } `, - ]); + ], + 'modal-container' + ); setModalContainer(newShadowRoot); }, []); diff --git a/src/content/index.tsx b/src/content/index.tsx index 724378f..e8b5228 100644 --- a/src/content/index.tsx +++ b/src/content/index.tsx @@ -5,10 +5,15 @@ import styles from '@/styles/shadow.css?inline'; import { createShadowRoot } from '@/lib/createShadowRoot'; import { ShadowRootContext } from '@/lib/ShadowRootContext'; import { TooltipProvider } from '@/components/ui/tooltip'; +import PlayerApp from '@/player/App'; +const HANSUNG_URL = 'https://learn.hansung.ac.kr/'; const footer = document.getElementById('page-footer'); +const leftMenus = document.getElementsByClassName('left-menus'); + const url = window.location.href; -if (footer && url === 'https://learn.hansung.ac.kr/') { + +if (footer && url === HANSUNG_URL) { const backtop = document.getElementById('back-top') as HTMLDivElement; if (backtop) backtop.remove(); @@ -33,7 +38,7 @@ if (footer && url === 'https://learn.hansung.ac.kr/') { host.style.backgroundColor = 'transparent'; placeholder.append(host); - const shadowRoot = createShadowRoot(host, [styles]); + const shadowRoot = createShadowRoot(host, [styles], 'popover'); createRoot(shadowRoot).render( @@ -45,3 +50,22 @@ if (footer && url === 'https://learn.hansung.ac.kr/') { ); } + +if (leftMenus.length === 2 && url.startsWith(HANSUNG_URL)) { + const leftMenu = leftMenus[0]; + + const host = document.createElement('div'); + host.id = 'dotbugi-player'; + host.style.backgroundColor = 'transparent'; + leftMenu.append(host); + + const shadowRoot = createShadowRoot(host, [styles], 'player'); + + createRoot(shadowRoot).render( + + + + + + ); +} diff --git a/src/hooks/useCourseData.tsx b/src/hooks/useCourseData.tsx index ed6f418..cb69377 100644 --- a/src/hooks/useCourseData.tsx +++ b/src/hooks/useCourseData.tsx @@ -150,17 +150,14 @@ export function useCourseData(courses: CourseBase[]) { loadDataFromStorage('vod', (data) => { if (!data) return; setVods((data as Vod[]).filter((vod) => isCurrentDateInRange(vod.range))); - // setVods((data as Vod[]) || []); }); loadDataFromStorage('assign', (data) => { if (!data) return; setAssigns((data as Assign[]).filter((assign) => isCurrentDateByDate(assign.dueDate))); - // setAssigns((data as Assign[]) || []); }); loadDataFromStorage('quiz', (data) => { if (!data) return; setQuizes((data as Quiz[]).filter((quiz) => isCurrentDateByDate(quiz.dueDate))); - // setQuizes((data as Quiz[]) || []); }); } }, [courses, updateData]); diff --git a/src/lib/createShadowRoot.ts b/src/lib/createShadowRoot.ts index 93f2ee6..948dcef 100644 --- a/src/lib/createShadowRoot.ts +++ b/src/lib/createShadowRoot.ts @@ -1,8 +1,7 @@ -export function createShadowRoot(host: HTMLElement, styles: string[]): ShadowRoot { +export function createShadowRoot(host: HTMLElement, styles: string[], shadowId: string): ShadowRoot { const shadowRoot = host.attachShadow({ mode: 'open' }); - // host에 데이터 속성 추가 - host.dataset.shadowId = 'extension-content-root'; + host.dataset.shadowId = shadowId; const sheets = styles.map((styleString) => { const sheet = new CSSStyleSheet(); diff --git a/src/lib/fetchAssign.ts b/src/lib/fetchAssign.ts index c4cdd48..d3f902a 100644 --- a/src/lib/fetchAssign.ts +++ b/src/lib/fetchAssign.ts @@ -42,7 +42,7 @@ export const fetchAssign = async (link: string) => { const isSubmit = row.querySelector(headerMap.isSubmit)?.textContent?.trim() === '미제출' ? false : true; if (sbj.length !== 0) subject = sbj; - if (!title || !url) return null; + if (!title || !url || !dueDate) return null; return { subject, title, url, dueDate, isSubmit }; }) .filter((assign) => assign !== null); diff --git a/src/lib/fetchIndexPage.ts b/src/lib/fetchIndexPage.ts index 74d5e43..63a039b 100644 --- a/src/lib/fetchIndexPage.ts +++ b/src/lib/fetchIndexPage.ts @@ -30,7 +30,7 @@ export const fetchIndexPage = async (link: string) => { const range = item.querySelector('.text-ubstrap')?.textContent?.trim() || ''; const length = item.querySelector('.text-info')?.textContent?.replace(',', '').trim() || ''; - if (!title || !url) return null; + if (!title || !url || !range) return null; return { week, subject, title, url, range, length }; }) .filter((item) => item !== null); diff --git a/src/lib/fetchQuiz.ts b/src/lib/fetchQuiz.ts index 8df0bf3..e5107ce 100644 --- a/src/lib/fetchQuiz.ts +++ b/src/lib/fetchQuiz.ts @@ -43,7 +43,7 @@ export const fetchQuiz = async (link: string) => { url = url.slice(0, index) + 'mod/quiz/' + url.slice(index); } - if (title && url) { + if (title && url && dueDate) { return { title, subject, url, dueDate }; } return null; diff --git a/src/option/App.tsx b/src/option/App.tsx index 91442e2..ebb41cc 100644 --- a/src/option/App.tsx +++ b/src/option/App.tsx @@ -42,6 +42,7 @@ const AnimatedRoutes = () => { animate="in" exit="out" variants={pageVariants} + //@ts-expect-error ignore transition={pageTransition} >
diff --git a/src/player/App.tsx b/src/player/App.tsx new file mode 100644 index 0000000..4bb1074 --- /dev/null +++ b/src/player/App.tsx @@ -0,0 +1,31 @@ +import { useState, useEffect } from 'react'; +import PlayerPopoverTrigger from './components/PlayerPopoverTrigger'; +import PlayerPopoverContent from './components/PlayerPopoverContent'; +import { Popover } from '@/components/ui/popover'; + +export default function PlayerApp() { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (isPlaying) { + e.preventDefault(); + return ''; + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [isPlaying]); + + return ( + + setIsPopoverOpen((prev) => !prev)} isPlaying={isPlaying} /> + + + ); +} diff --git a/src/player/components/PlayerIframe.tsx b/src/player/components/PlayerIframe.tsx new file mode 100644 index 0000000..877ede4 --- /dev/null +++ b/src/player/components/PlayerIframe.tsx @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState } from 'react'; + +interface PlayerIframeProps { + videoSrc: string; + onNextVideo: () => void; + isPlaying: boolean; +} + +export default function PlayerIframe({ videoSrc, onNextVideo, isPlaying }: PlayerIframeProps) { + const iframeRef = useRef(null); + const isPlayingRef = useRef(isPlaying); + const onNextVideoRef = useRef(onNextVideo); + + const [time, setTime] = useState({ current: 0, duration: 0 }); + + useEffect(() => { + const interval = setInterval(() => { + const video = getVideoElement(); + if (video) { + setTime({ current: video.currentTime, duration: video.duration }); + } + }, 500); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + isPlayingRef.current = isPlaying; + onNextVideoRef.current = onNextVideo; + }, [isPlaying, onNextVideo]); + + // iframe 내 video 요소 접근 헬퍼 + const getVideoElement = () => { + const iframe = iframeRef.current; + if (!iframe) return null; + try { + const doc = iframe.contentDocument || iframe.contentWindow?.document; + return (doc?.querySelector('video') as HTMLVideoElement | null) ?? null; + } catch (err) { + console.error('iframe 접근 에러', err); + return null; + } + }; + + // 재생, 일시정지 처리 함수 + const controlPlayback = (video: HTMLVideoElement | null, play: boolean) => { + if (!video) return; + if (play) { + video.muted = false; + video.volume = 0.01; + video.play().catch((e) => console.warn('재생 실패:', e)); + } else { + video.pause(); + } + }; + + // load 이벤트 시 video listener 세팅 + const attachVideoListeners = () => { + const video = getVideoElement(); + if (!video) return; + + // 중복 방지 + video.onended = null; + video.onended = () => onNextVideoRef.current(); + + controlPlayback(video, isPlayingRef.current); + }; + + // iframe load 이벤트 연결 + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + iframe.addEventListener('load', attachVideoListeners); + return () => iframe.removeEventListener('load', attachVideoListeners); + }, [videoSrc]); + + // isPlaying 변경 시 재생 상태 제어 + useEffect(() => { + controlPlayback(getVideoElement(), isPlaying); + }, [isPlaying]); + + useEffect(() => { + const interval = setInterval(() => { + const video = getVideoElement(); + if (video && !video.paused) { + video.play().catch(() => {}); + } + }, 60_000); + return () => clearInterval(interval); + }, []); + + return ( +
+
+ {time.duration > 0 ? ( +
+ + {Math.floor(time.current / 60)}: + {Math.floor(time.current % 60) + .toString() + .padStart(2, '0')}{' '} + + / + + {Math.floor(time.duration / 60)}: + {Math.floor(time.duration % 60) + .toString() + .padStart(2, '0')} + +
+ ) : ( +
영상 정보가 없습니다
+ )} +
+ +