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')}
+
+
+ ) : (
+
영상 정보가 없습니다
+ )}
+
+
+
+
+ );
+}
diff --git a/src/player/components/PlayerPopoverContent.tsx b/src/player/components/PlayerPopoverContent.tsx
new file mode 100644
index 0000000..45d5106
--- /dev/null
+++ b/src/player/components/PlayerPopoverContent.tsx
@@ -0,0 +1,195 @@
+import { PopoverContent } from '@radix-ui/react-popover';
+import PlayerIframe from './PlayerIframe';
+import type { Vod } from '@/content/types';
+import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
+import { loadDataFromStorage } from '@/lib/storage';
+import { Button } from '@/components/ui/button';
+
+import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, DragEndEvent } from '@dnd-kit/core';
+import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
+import SortableItem from './SortableItem';
+import { isCurrentDateInRange } from '@/lib/utils';
+
+interface PlayerPopoverContentProps {
+ isPopoverOpen: boolean;
+ isPlaying: boolean;
+ setIsPlaying: Dispatch>;
+}
+
+export default function PlayerPopoverContent({ isPopoverOpen, isPlaying, setIsPlaying }: PlayerPopoverContentProps) {
+ const [currentVideoIndex, setCurrentVideoIndex] = useState(0);
+ const [vods, setVods] = useState([]);
+ const [isDone, setIsDone] = useState(false);
+ const [isHover, setIsHover] = useState(false);
+
+ const parseTimeToSeconds = (timeStr: string): number => {
+ const parts = timeStr.split(':');
+ if (parts.length === 2) {
+ const [minutes, seconds] = parts.map(Number);
+ return minutes * 60 + seconds;
+ }
+ return 0;
+ };
+
+ const totalDurationSeconds = useMemo(() => {
+ return vods.reduce((sum, vod) => sum + parseTimeToSeconds(vod.length), 0);
+ }, [vods]);
+
+ const formatExpectedEndTime = (seconds: number): string => {
+ const endTime = new Date(Date.now() + seconds * 1000);
+ const month = endTime.getMonth() + 1;
+ const date = endTime.getDate();
+ const hours = endTime.getHours();
+ const minutes = endTime.getMinutes().toString().padStart(2, '0');
+ return `${month}/${date} ${hours}:${minutes}`;
+ };
+
+ const onNextVideo = useCallback(() => {
+ if (currentVideoIndex + 1 >= vods.length) {
+ setIsPlaying(false);
+ setIsDone(true);
+ return;
+ }
+
+ setCurrentVideoIndex((prev) => (prev + 1) % vods.length);
+ }, [vods, currentVideoIndex, setIsPlaying]);
+
+ useEffect(() => {
+ loadDataFromStorage('vod', (data) => {
+ if (!data) return;
+ const filtered = (data as Vod[]).filter(
+ (vod) => isCurrentDateInRange(vod.range) && vod.isAttendance.toLowerCase() !== 'o'
+ );
+ setVods(filtered);
+ });
+ }, []);
+
+ useEffect(() => {
+ if (isPopoverOpen && !isPlaying && vods.length === 0) {
+ loadDataFromStorage('vod', (data) => {
+ if (!data) return;
+ const filtered = (data as Vod[]).filter(
+ (vod) => isCurrentDateInRange(vod.range) && vod.isAttendance.toLowerCase() !== 'o'
+ );
+ setVods(filtered);
+ });
+ }
+ }, [isPlaying, vods, isPopoverOpen]);
+
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ delay: 100,
+ tolerance: 5,
+ },
+ })
+ );
+
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over) return;
+ if (active.id !== over.id) {
+ const oldIndex = vods.findIndex((v) => `${v.week}-${v.title}` === active.id);
+ const newIndex = vods.findIndex((v) => `${v.week}-${v.title}` === over.id);
+ setVods((items) => arrayMove(items, oldIndex, newIndex));
+ }
+ };
+
+ const autoPlayText =
+ isHover && isPlaying
+ ? '수강 종료'
+ : isPlaying
+ ? `${formatExpectedEndTime(totalDurationSeconds)} 완료 예정`
+ : isDone
+ ? '수강 완료'
+ : '수강 시작';
+
+ return (
+
+
+ {vods.length !== 0 ? (
+
+
+
+
+
+
강의 목록
+
+
+
+
+ `${v.week}-${v.title}`)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {vods.map((vod, idx) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+ 수강할 영상이 없습니다
+
+
+ 이클래스 돋부기를 눌러 강의를 새로고침 해주세요
+
+
+ )}
+
+
+ );
+}
diff --git a/src/player/components/PlayerPopoverTrigger.tsx b/src/player/components/PlayerPopoverTrigger.tsx
new file mode 100644
index 0000000..3e0eab3
--- /dev/null
+++ b/src/player/components/PlayerPopoverTrigger.tsx
@@ -0,0 +1,44 @@
+import { PopoverTrigger } from '@/components/ui/popover';
+import icon from '@/assets/icon.png';
+import { BorderTrail } from '@/components/ui/border-trail';
+
+interface PlayerPopoverTriggerProps {
+ onClick: () => void;
+ isPlaying: boolean;
+}
+
+export default function PlayerPopoverTrigger({ onClick, isPlaying }: PlayerPopoverTriggerProps) {
+ return (
+
+
+
+

+
+
돋부기 🔎
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/player/components/SortableItem.tsx b/src/player/components/SortableItem.tsx
new file mode 100644
index 0000000..c58a4d2
--- /dev/null
+++ b/src/player/components/SortableItem.tsx
@@ -0,0 +1,47 @@
+import { Vod } from '@/content/types';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { Dispatch, SetStateAction } from 'react';
+
+interface SortableItemProps {
+ vod: Vod;
+ idx: number;
+ currentVideoIndex: number;
+ setCurrentVideoIndex: Dispatch>;
+}
+export default function SortableItem({ vod, idx, currentVideoIndex, setCurrentVideoIndex }: SortableItemProps) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: `${vod.week}-${vod.title}`,
+ });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ cursor: 'grab',
+ };
+
+ return (
+ setCurrentVideoIndex(idx)}
+ className={`p-3 rounded-lg transition-all flex-shrink-0 ${
+ idx === currentVideoIndex
+ ? 'bg-white border border-blue-400'
+ : 'bg-zinc-50 border border-transparent hover:border-zinc-200'
+ }`}
+ >
+
+
+
{vod.title}
+
+ {vod.courseTitle} - {vod.prof}
+
+
+
+
+ );
+}