Aditya DN commited on
Commit
5372a29
·
verified ·
1 Parent(s): 84b5259

Upload 6 files

Browse files
components/LoadingScreen.tsx CHANGED
@@ -1,80 +1,80 @@
1
- import React, { useState, useEffect } from 'react';
2
-
3
- interface LoadingScreenProps {
4
- isBar?: boolean;
5
- progress?: number;
6
- }
7
-
8
- const LoadingScreen: React.FC<LoadingScreenProps> = ({ isBar = false, progress = 0 }) => {
9
- const [dots, setDots] = useState('.');
10
-
11
- useEffect(() => {
12
- const dotAnimation = setInterval(() => {
13
- setDots(prevDots => {
14
- if (prevDots.length >= 5) {
15
- return '.';
16
- }
17
- return prevDots + '.';
18
- });
19
- }, 1000);
20
-
21
- return () => clearInterval(dotAnimation);
22
- }, []);
23
-
24
- return (
25
- <div className="fixed inset-0 z-50 flex h-screen w-screen flex-col items-center justify-center bg-bg font-sans text-fg">
26
- <h1 className="mb-8 text-3xl font-bold">Mempersiapkan Presentasi...</h1>
27
-
28
- {isBar ? (
29
- <>
30
- <div style={styles.progressBarContainer}>
31
- <div style={{ ...styles.progressBar, width: `${progress}%` }}></div>
32
- </div>
33
- <p className="mt-4 text-xl font-medium">{Math.round(progress)}%</p>
34
- </>
35
- ) : (
36
- <div className='flex flex-row gap-10'>
37
- <h2 className="mt-8 text-3xl font-bold">
38
- LOADING
39
- <span className="w-12 text-left">{dots}</span>
40
- </h2>
41
- </div>
42
- )}
43
- </div>
44
- );
45
- };
46
-
47
- const styles: { [key: string]: React.CSSProperties } = {
48
- container: {
49
- width: '100vw',
50
- height: '100vh',
51
- display: 'flex',
52
- flexDirection: 'column',
53
- justifyContent: 'center',
54
- alignItems: 'center',
55
- backgroundColor: '#071026',
56
- color: '#e6eef8',
57
- fontFamily: 'Inter, system-ui, sans-serif',
58
- },
59
- title: {
60
- marginBottom: '2rem',
61
- },
62
- progressBarContainer: {
63
- width: '50%',
64
- maxWidth: '500px',
65
- height: '20px',
66
- backgroundColor: '#0e2335',
67
- borderRadius: '10px',
68
- overflow: 'hidden',
69
- },
70
- progressBar: {
71
- height: '100%',
72
- backgroundColor: '#60a5fa',
73
- },
74
- progressText: {
75
- marginTop: '1rem',
76
- fontSize: '1.2rem',
77
- }
78
- };
79
-
80
- export default LoadingScreen;
 
1
+ import React, { useState, useEffect } from 'react';
2
+
3
+ interface LoadingScreenProps {
4
+ isBar?: boolean;
5
+ progress?: number;
6
+ }
7
+
8
+ const LoadingScreen: React.FC<LoadingScreenProps> = ({ isBar = false, progress = 0 }) => {
9
+ const [dots, setDots] = useState('.');
10
+
11
+ useEffect(() => {
12
+ const dotAnimation = setInterval(() => {
13
+ setDots(prevDots => {
14
+ if (prevDots.length >= 5) {
15
+ return '.';
16
+ }
17
+ return prevDots + '.';
18
+ });
19
+ }, 1000);
20
+
21
+ return () => clearInterval(dotAnimation);
22
+ }, []);
23
+
24
+ return (
25
+ <div className="fixed inset-0 z-50 flex h-screen w-screen flex-col items-center justify-center bg-bg font-sans text-fg">
26
+ <h1 className="mb-8 text-3xl font-bold">Mempersiapkan Presentasi...</h1>
27
+
28
+ {isBar ? (
29
+ <>
30
+ <div style={styles.progressBarContainer}>
31
+ <div style={{ ...styles.progressBar, width: `${progress}%` }}></div>
32
+ </div>
33
+ <p className="mt-4 text-xl font-medium">{Math.round(progress)}%</p>
34
+ </>
35
+ ) : (
36
+ <div className='flex flex-row gap-10'>
37
+ <h2 className="mt-8 text-3xl font-bold">
38
+ LOADING
39
+ <span className="w-12 text-left">{dots}</span>
40
+ </h2>
41
+ </div>
42
+ )}
43
+ </div>
44
+ );
45
+ };
46
+
47
+ const styles: { [key: string]: React.CSSProperties } = {
48
+ container: {
49
+ width: '100vw',
50
+ height: '100vh',
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ justifyContent: 'center',
54
+ alignItems: 'center',
55
+ backgroundColor: '#071026',
56
+ color: '#e6eef8',
57
+ fontFamily: 'Inter, system-ui, sans-serif',
58
+ },
59
+ title: {
60
+ marginBottom: '2rem',
61
+ },
62
+ progressBarContainer: {
63
+ width: '50%',
64
+ maxWidth: '500px',
65
+ height: '20px',
66
+ backgroundColor: '#0e2335',
67
+ borderRadius: '10px',
68
+ overflow: 'hidden',
69
+ },
70
+ progressBar: {
71
+ height: '100%',
72
+ backgroundColor: '#60a5fa',
73
+ },
74
+ progressText: {
75
+ marginTop: '1rem',
76
+ fontSize: '1.2rem',
77
+ }
78
+ };
79
+
80
+ export default LoadingScreen;
components/LoginModal.tsx CHANGED
@@ -1,144 +1,144 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- import { Mode } from '@/types';
3
-
4
- interface LoginModalProps {
5
- onLoginSuccess: (mode: Mode) => void;
6
- onClose: () => void;
7
- }
8
-
9
- const LoginModal: React.FC<LoginModalProps> = ({ onLoginSuccess, onClose }) => {
10
- const [username, setUsername] = useState('');
11
- const [password, setPassword] = useState('');
12
- const [hasFocused, setHasFocused] = useState(false);
13
- const [error, setError] = useState<string | null>(null);
14
- const [isSubmitting, setIsSubmitting] = useState(false);
15
- const modalRef = useRef<HTMLDivElement>(null);
16
- const usernameRef = useRef<HTMLInputElement>(null);
17
-
18
- const CONTROLLER_NAME = process.env.CONTROLLER_NAME || '';
19
- const CONTROLLER_PASSWORD = process.env.CONTROLLER_PASSWORD || '';
20
-
21
- if (!CONTROLLER_NAME || !CONTROLLER_PASSWORD) {
22
- throw new Error("Environment variables for login credentials are not set.");
23
- }
24
-
25
- useEffect(() => {
26
- if (!hasFocused) {
27
- setHasFocused(true);
28
- usernameRef.current?.focus();
29
- }
30
-
31
- const handleEsc = (event: KeyboardEvent) => {
32
- if (event.key === 'Escape') {
33
- onClose();
34
- }
35
- };
36
- window.addEventListener('keydown', handleEsc);
37
-
38
- return () => {
39
- window.removeEventListener('keydown', handleEsc);
40
- };
41
- }, [onClose]);
42
-
43
- const handleSubmit = (e: React.FormEvent) => {
44
- e.preventDefault();
45
- setError(null);
46
- setIsSubmitting(true);
47
-
48
- setTimeout(() => {
49
- if (username === CONTROLLER_NAME && password === CONTROLLER_PASSWORD) {
50
- onLoginSuccess('controller');
51
- } else {
52
- setError('Username atau PIN/Password salah. Silakan coba lagi.');
53
- }
54
- setIsSubmitting(false);
55
- }, 500);
56
- };
57
-
58
- const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
59
- if (modalRef.current && e.target === modalRef.current) {
60
- onClose();
61
- }
62
- };
63
-
64
- return (
65
- <div
66
- ref={modalRef}
67
- onClick={handleBackdropClick}
68
- className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 transition-opacity duration-300 animate-fade-in"
69
- >
70
- <div className="bg-gray-800 border border-gray-700 rounded-lg shadow-xl p-6 sm:p-8 w-full max-w-sm transform animate-scale-in">
71
- <h2 className="text-2xl font-bold text-center text-indigo-400 mb-6">Login</h2>
72
- <form onSubmit={handleSubmit} className="space-y-4">
73
- <div>
74
- <label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-1">
75
- Username
76
- </label>
77
- <input
78
- ref={usernameRef}
79
- type="text"
80
- id="username"
81
- value={username}
82
- onChange={(e) => setUsername(e.target.value)}
83
- className="w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
84
- required
85
- />
86
- </div>
87
- <div>
88
- <label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
89
- PIN / Password
90
- </label>
91
- <input
92
- type="password"
93
- id="password"
94
- value={password}
95
- onChange={(e) => setPassword(e.target.value)}
96
- className="w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
97
- required
98
- />
99
- </div>
100
- {error && <p className="text-red-400 text-sm text-center">{error}</p>}
101
- <div className="pt-2 flex flex-col sm:flex-row-reverse gap-3">
102
- <button
103
- type="submit"
104
- disabled={isSubmitting}
105
- className="w-full px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-md font-semibold text-white transition-colors disabled:bg-indigo-800 disabled:cursor-not-allowed flex items-center justify-center"
106
- >
107
- {isSubmitting && <SpinnerIcon />}
108
- {isSubmitting ? 'Memverifikasi...' : 'Login'}
109
- </button>
110
- <button
111
- type="button"
112
- onClick={onClose}
113
- className="w-full px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md font-semibold text-white transition-colors"
114
- >
115
- Batal
116
- </button>
117
- </div>
118
- </form>
119
- </div>
120
- <style>{`
121
- @keyframes fade-in {
122
- from { opacity: 0; }
123
- to { opacity: 1; }
124
- }
125
- @keyframes scale-in {
126
- from { opacity: 0; transform: scale(0.95); }
127
- to { opacity: 1; transform: scale(1); }
128
- }
129
- .animate-fade-in { animation: fade-in 0.3s ease-out forwards; }
130
- .animate-scale-in { animation: scale-in 0.3s ease-out forwards; }
131
- `}</style>
132
- </div>
133
- );
134
- };
135
-
136
- const SpinnerIcon = () => (
137
- <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
138
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
139
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
140
- </svg>
141
- );
142
-
143
-
144
- export default LoginModal;
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Mode } from '@/types';
3
+
4
+ interface LoginModalProps {
5
+ onLoginSuccess: (mode: Mode) => void;
6
+ onClose: () => void;
7
+ }
8
+
9
+ const LoginModal: React.FC<LoginModalProps> = ({ onLoginSuccess, onClose }) => {
10
+ const [username, setUsername] = useState('');
11
+ const [password, setPassword] = useState('');
12
+ const [hasFocused, setHasFocused] = useState(false);
13
+ const [error, setError] = useState<string | null>(null);
14
+ const [isSubmitting, setIsSubmitting] = useState(false);
15
+ const modalRef = useRef<HTMLDivElement>(null);
16
+ const usernameRef = useRef<HTMLInputElement>(null);
17
+
18
+ const CONTROLLER_NAME = process.env.CONTROLLER_NAME || '';
19
+ const CONTROLLER_PASSWORD = process.env.CONTROLLER_PASSWORD || '';
20
+
21
+ if (!CONTROLLER_NAME || !CONTROLLER_PASSWORD) {
22
+ throw new Error("Environment variables for login credentials are not set.");
23
+ }
24
+
25
+ useEffect(() => {
26
+ if (!hasFocused) {
27
+ setHasFocused(true);
28
+ usernameRef.current?.focus();
29
+ }
30
+
31
+ const handleEsc = (event: KeyboardEvent) => {
32
+ if (event.key === 'Escape') {
33
+ onClose();
34
+ }
35
+ };
36
+ window.addEventListener('keydown', handleEsc);
37
+
38
+ return () => {
39
+ window.removeEventListener('keydown', handleEsc);
40
+ };
41
+ }, [onClose]);
42
+
43
+ const handleSubmit = (e: React.FormEvent) => {
44
+ e.preventDefault();
45
+ setError(null);
46
+ setIsSubmitting(true);
47
+
48
+ setTimeout(() => {
49
+ if (username === CONTROLLER_NAME && password === CONTROLLER_PASSWORD) {
50
+ onLoginSuccess('controller');
51
+ } else {
52
+ setError('Username atau PIN/Password salah. Silakan coba lagi.');
53
+ }
54
+ setIsSubmitting(false);
55
+ }, 500);
56
+ };
57
+
58
+ const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
59
+ if (modalRef.current && e.target === modalRef.current) {
60
+ onClose();
61
+ }
62
+ };
63
+
64
+ return (
65
+ <div
66
+ ref={modalRef}
67
+ onClick={handleBackdropClick}
68
+ className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 transition-opacity duration-300 animate-fade-in"
69
+ >
70
+ <div className="bg-gray-800 border border-gray-700 rounded-lg shadow-xl p-6 sm:p-8 w-full max-w-sm transform animate-scale-in">
71
+ <h2 className="text-2xl font-bold text-center text-indigo-400 mb-6">Login</h2>
72
+ <form onSubmit={handleSubmit} className="space-y-4">
73
+ <div>
74
+ <label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-1">
75
+ Username
76
+ </label>
77
+ <input
78
+ ref={usernameRef}
79
+ type="text"
80
+ id="username"
81
+ value={username}
82
+ onChange={(e) => setUsername(e.target.value)}
83
+ className="w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
84
+ required
85
+ />
86
+ </div>
87
+ <div>
88
+ <label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
89
+ PIN / Password
90
+ </label>
91
+ <input
92
+ type="password"
93
+ id="password"
94
+ value={password}
95
+ onChange={(e) => setPassword(e.target.value)}
96
+ className="w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
97
+ required
98
+ />
99
+ </div>
100
+ {error && <p className="text-red-400 text-sm text-center">{error}</p>}
101
+ <div className="pt-2 flex flex-col sm:flex-row-reverse gap-3">
102
+ <button
103
+ type="submit"
104
+ disabled={isSubmitting}
105
+ className="w-full px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-md font-semibold text-white transition-colors disabled:bg-indigo-800 disabled:cursor-not-allowed flex items-center justify-center"
106
+ >
107
+ {isSubmitting && <SpinnerIcon />}
108
+ {isSubmitting ? 'Memverifikasi...' : 'Login'}
109
+ </button>
110
+ <button
111
+ type="button"
112
+ onClick={onClose}
113
+ className="w-full px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md font-semibold text-white transition-colors"
114
+ >
115
+ Batal
116
+ </button>
117
+ </div>
118
+ </form>
119
+ </div>
120
+ <style>{`
121
+ @keyframes fade-in {
122
+ from { opacity: 0; }
123
+ to { opacity: 1; }
124
+ }
125
+ @keyframes scale-in {
126
+ from { opacity: 0; transform: scale(0.95); }
127
+ to { opacity: 1; transform: scale(1); }
128
+ }
129
+ .animate-fade-in { animation: fade-in 0.3s ease-out forwards; }
130
+ .animate-scale-in { animation: scale-in 0.3s ease-out forwards; }
131
+ `}</style>
132
+ </div>
133
+ );
134
+ };
135
+
136
+ const SpinnerIcon = () => (
137
+ <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
138
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
139
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
140
+ </svg>
141
+ );
142
+
143
+
144
+ export default LoginModal;
components/Player.tsx CHANGED
@@ -1,314 +1,314 @@
1
- import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
- import { getData, saveData } from '../services/githubService';
3
- import { type Mode, type GitHubData, defaultData, LastAction, reversePaths, realPaths } from '../types';
4
- import LoginModal from './LoginModal';
5
- import UpdateRefreshDataMode from './UpdateRefreshDataMode';
6
- import { FaEllipsisV } from 'react-icons/fa';
7
-
8
- // Tipe untuk props
9
- interface PlayerProps {
10
- preloadedUrls: Record<string, string>; // Kunci: path asli, Nilai: Object URL
11
- totalSegments: number;
12
- }
13
-
14
- const Player: React.FC<PlayerProps> = ({ preloadedUrls, totalSegments }) => {
15
- // === State & Refs ===
16
- const [mode, setMode] = useState<Mode>('controller');
17
- const [isMobileMenuOpen, setMobileMenuOpen] = useState(false)
18
- const [isLoginVisible, setLoginVisible] = useState(false);
19
- const [githubData, setGithubData] = useState<GitHubData>(defaultData);
20
- const [sha, setSha] = useState("");
21
- const [isPlaying, setPlaying] = useState(false);
22
- const [currentSegmentIdx, setCurrentSegmentIdx] = useState(0);
23
- const saveRef = useRef<HTMLButtonElement>(null);
24
- const appWrapRef = useRef<HTMLDivElement>(null);
25
- const [lastActionsState, setLastActionsState] = useState<[LastAction, LastAction]>([null, null]);
26
-
27
- const [activePlayerIndex, setActivePlayerIndex] = useState(0);
28
-
29
- const videoRefs = [
30
- useRef<HTMLVideoElement>(null),
31
- useRef<HTMLVideoElement>(null),
32
- useRef<HTMLVideoElement>(null),
33
- ];
34
-
35
- const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
36
-
37
- useEffect(() => {
38
- videoRefs.forEach((ref, index) => {
39
- const video = ref.current;
40
- if (video && video.src) {
41
- if (index === activePlayerIndex) {
42
- video.play().catch(e => {});
43
- } else {
44
- console.log({currentSegmentIdx});
45
- if (currentSegmentIdx === 0) {
46
- video.currentTime = 0;
47
- }
48
- video.pause();
49
- }
50
- }
51
- });
52
- }, [activePlayerIndex]);
53
-
54
- const updatePlayerAndBuffers = useCallback((isSaveOnly: boolean, newIndex?: number, direction?: LastAction) => {
55
- console.log({currentSegmentIdx, newIndex, direction});
56
- if (newIndex < 0 || newIndex > totalSegments - 1) return;
57
-
58
- (async () => {
59
- if (mode !== 'controller' && false) return;
60
-
61
- const dataToSave: GitHubData = {
62
- idx: newIndex,
63
- direction: direction
64
- };
65
-
66
- let newSha = sha;
67
-
68
- try {
69
- const response = await getData();
70
- newSha = response.sha;
71
- } catch (error) {
72
- console.error("Gagal mengambil data sebelum menyimpan:", error);
73
- }
74
-
75
- try {
76
- await saveData(dataToSave, "Update data", newSha);
77
- console.log('Data berhasil disimpan!');
78
- } catch (error) {
79
- console.error("Gagal menyimpan data:", error);
80
- }
81
- })();
82
-
83
- if (isSaveOnly) return;
84
-
85
- // Tentukan player mana yang akan menjadi aktif
86
- const nextPlayerIndex = (activePlayerIndex + 1) % 3;
87
-
88
- // Tentukan URL untuk player yang akan aktif
89
- const activeUrlPath = direction === 'prev'
90
- ? `${reversePaths}${newIndex}.mp4`
91
- : `${realPaths}${newIndex}.mp4`;
92
-
93
- // Set src untuk player yang akan aktif
94
- const activePlayer = videoRefs[nextPlayerIndex].current;
95
- if (activePlayer) {
96
- activePlayer.src = preloadedUrls[activeUrlPath];
97
- }
98
-
99
- // Ganti player yang aktif
100
- setActivePlayerIndex(nextPlayerIndex);
101
- setCurrentSegmentIdx(newIndex);
102
- setLastActionsState(prev => [prev[1], direction]);
103
-
104
- // Update buffer untuk player yang tidak aktif
105
- const nextBufferPlayer = videoRefs[(nextPlayerIndex + 1) % 3].current;
106
- const prevBufferPlayer = videoRefs[(nextPlayerIndex + 2) % 3].current;
107
-
108
- if (nextBufferPlayer && newIndex < totalSegments - 1) {
109
- nextBufferPlayer.src = preloadedUrls[`${realPaths}${newIndex + 1}.mp4`];
110
- }
111
-
112
- if (prevBufferPlayer && newIndex > 0) {
113
- prevBufferPlayer.src = preloadedUrls[`${reversePaths}${newIndex - 1}.mp4`];
114
- }
115
- }, [activePlayerIndex, totalSegments]);
116
-
117
- const handleNext = useCallback(() => {
118
- if (isPlaying) return;
119
- const nextIdx = currentSegmentIdx + (lastActionsState[1] === null ? 0 : 1) - (lastActionsState[1] === 'prev' ? 1 : (lastActionsState[1] === null ? -1 : 0));
120
- updatePlayerAndBuffers(false, nextIdx, 'next');
121
- setLastActionsState(prev => [prev[1], 'next']);
122
- }, [currentSegmentIdx, isPlaying, lastActionsState, totalSegments, preloadedUrls]);
123
-
124
- const handlePrev = useCallback(() => {
125
- if (isPlaying) return;
126
- const prevIdx = currentSegmentIdx - (lastActionsState[1] === null ? 0 : 1) + (['next', 'jump'].includes(lastActionsState[1]) ? 1 : 0);
127
- updatePlayerAndBuffers(false, prevIdx, 'prev');
128
- setLastActionsState(prev => [prev[1], 'prev']);
129
- }, [currentSegmentIdx, isPlaying, lastActionsState, totalSegments, preloadedUrls]);
130
-
131
- const handleSkip = useCallback(() => {
132
- const activePlayer = videoRefs[activePlayerIndex].current;
133
- if (isPlaying && activePlayer) {
134
- activePlayer.currentTime = activePlayer.duration - 0.01;
135
- activePlayer.pause();
136
- }
137
- }, [currentSegmentIdx, isPlaying, lastActionsState, totalSegments, preloadedUrls]);
138
-
139
- const handleSegmentInputChange = (e: React.ChangeEvent<HTMLInputElement> | number) => {
140
- const page = clamp(Number(typeof e === 'number' ? e : e.target.value), 0, totalSegments - 1);
141
- updatePlayerAndBuffers(false, page, 'jump');
142
- setLastActionsState(prev => [prev[1], 'jump']);
143
- }
144
-
145
- const toggleFullScreen = () => {
146
- if (!document.fullscreenElement) {
147
- appWrapRef.current?.requestFullscreen();
148
- } else {
149
- document.exitFullscreen();
150
- }
151
- };
152
-
153
- // === Logika Sinkronisasi Data (githubService) ===
154
- const fetchData = useCallback(async () => {
155
- try {
156
- const response = await getData();
157
- setGithubData(response.data);
158
- setSha(response.sha);
159
- } catch (error) {
160
- console.error("Gagal mengambil data:", error);
161
- }
162
- }, []);
163
-
164
- // Handler untuk event keyboard
165
- useEffect(() => {
166
- const handleKeyDown = (event: KeyboardEvent) => {
167
- // Hanya aktifkan shortcut keyboard jika mode adalah presenter
168
- if (mode === 'controller') {
169
- if (event.key === 'ArrowRight') {
170
- isPlaying ? handleSkip() : handleNext();
171
- } else if (event.key === 'ArrowLeft') {
172
- isPlaying ? handleSkip() : handlePrev();
173
- } else if (event.key === 's') {
174
- updatePlayerAndBuffers(true, currentSegmentIdx, lastActionsState[1])
175
- }
176
- }
177
- };
178
- window.addEventListener('keydown', handleKeyDown);
179
- return () => window.removeEventListener('keydown', handleKeyDown);
180
- }, [isPlaying, handleNext, handlePrev, mode]); // Tambahkan mode sebagai dependency
181
-
182
- // Polling data untuk mode 'preview'
183
- useEffect(() => {
184
- if (mode === 'preview') {
185
- fetchData(); // Ambil data saat pertama kali masuk mode preview
186
- const intervalId = setInterval(fetchData, 5000);
187
- return () => clearInterval(intervalId);
188
- }
189
- }, [mode, fetchData]);
190
-
191
- // Reaksi terhadap perubahan data yang di-fetch dalam mode 'preview'
192
- useEffect(() => {
193
- const isPreviewMode = mode === 'preview';
194
- const hasChangedGithubData = githubData && (githubData.idx !== currentSegmentIdx || githubData.direction !== lastActionsState[1]);
195
- const someVideoIsNull = videoRefs.some(
196
- videoRef => !videoRef.current || !videoRef.current.src || videoRef.current.src === "undefined"
197
- );
198
-
199
- console.log({ preloadedUrls: Object.values(preloadedUrls) })
200
-
201
- console.log(isPreviewMode, hasChangedGithubData, someVideoIsNull);
202
- console.log(mode, JSON.stringify(githubData), JSON.stringify(videoRefs.map(videoRef => videoRef.current.src || undefined)));
203
-
204
- if (isPreviewMode && (hasChangedGithubData || someVideoIsNull)) {
205
- console.log('Data baru terdeteksi:', githubData);
206
- updatePlayerAndBuffers(false, githubData.idx, githubData.direction);
207
- }
208
- }, [githubData, mode, preloadedUrls]);
209
-
210
- return (
211
- <>
212
- <main ref={appWrapRef} className="fixed inset-0 z-10 flex flex-col items-center justify-start w-screen h-screen bg-bg">
213
- <div className="group fixed inset-0 flex h-screen w-screen items-end justify-center bg-black">
214
- {videoRefs.map((ref, index) => (
215
- <video
216
- key={index}
217
- ref={ref}
218
- className="absolute inset-0 h-full w-full object-contain bg-black"
219
- style={{ zIndex: activePlayerIndex === index ? 1 : 0 }}
220
- onPlay={() => activePlayerIndex === index && setPlaying(true)}
221
- onPause={() => activePlayerIndex === index && setPlaying(false)}
222
- onEnded={() => activePlayerIndex === index && setPlaying(false)}
223
- playsInline
224
- preload="auto"
225
- muted
226
- />
227
- ))}
228
-
229
- <div className="absolute inset-x-0 bottom-0 sm:h-auto h-[25vh] z-20 flex w-full justify-center opacity-100 sm:opacity-0 transition-opacity duration-300 hover:opacity-100 backdrop-blur-sm">
230
- <div className="flex w-full items-center justify-between rounded-t-2xl bg-bg/50 px-6 py-4 shadow-lg">
231
- <div className="flex items-center justify-center gap-2.5">
232
- <button
233
- id="prevBtn"
234
- className="btn"
235
- onClick={handlePrev}
236
- disabled={mode === 'preview' || isPlaying || (currentSegmentIdx <= 0 && lastActionsState[1] !== "prev")}
237
- >⟨</button>
238
- <button
239
- id="nextBtn"
240
- className="btn"
241
- onClick={handleNext}
242
- disabled={mode === 'preview' || isPlaying || currentSegmentIdx >= totalSegments - 1}
243
- >⟩</button>
244
- {isPlaying && <button id="skipBtn" className="btn" onClick={handleSkip}>⟨⟨ ⟩⟩</button>}
245
- </div>
246
-
247
- <div className="hidden items-center gap-4 sm:flex">
248
- <UpdateRefreshDataMode
249
- mode={mode}
250
- saveRef={saveRef}
251
- onFetch={fetchData}
252
- onSave={() => updatePlayerAndBuffers(true, currentSegmentIdx, lastActionsState[1])}
253
- onLogin={() => setLoginVisible(true)}
254
- onLogout={() => setMode('preview')}
255
- isMobile={isMobileMenuOpen}
256
- />
257
- </div>
258
-
259
- <div className="flex items-center justify-center">
260
- <input
261
- type="number"
262
- id="inputSegment"
263
- className="btn w-[55px] text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
264
- min="0"
265
- max={totalSegments - 1}
266
- value={String(currentSegmentIdx)}
267
- onChange={(e) => setCurrentSegmentIdx(Number(e.target.value))}
268
- onBlur={handleSegmentInputChange}
269
- onKeyDown={(e) => {
270
- if (e.key === 'Enter') handleSegmentInputChange(currentSegmentIdx);
271
- }}
272
- disabled={mode === 'preview'}
273
- />
274
- <button id="fsBtn" className="btn small ml-2.5" onClick={toggleFullScreen}>⤢</button>
275
- </div>
276
- </div>
277
- </div>
278
-
279
- <div className="absolute top-0 right-0 z-30 p-4 sm:hidden">
280
- <button onClick={() => setMobileMenuOpen(!isMobileMenuOpen)} className="btn small">
281
- <FaEllipsisV />
282
- </button>
283
- </div>
284
- </div>
285
-
286
- <div className={`fixed bottom-0 z-40 w-full p-4 bg-bg/80 backdrop-blur-sm transition-transform duration-300 ease-in-out sm:hidden ${isMobileMenuOpen ? 'translate-y-0' : 'translate-y-full'}`}>
287
- <div className="flex flex-col items-center gap-4">
288
- <UpdateRefreshDataMode
289
- mode={mode}
290
- saveRef={saveRef}
291
- onFetch={fetchData}
292
- onSave={() => updatePlayerAndBuffers(true, currentSegmentIdx, lastActionsState[1])}
293
- onLogin={() => setLoginVisible(true)}
294
- onLogout={() => setMode('preview')}
295
- isMobile={isMobileMenuOpen}
296
- />
297
- <button onClick={() => setMobileMenuOpen(false)} className="btn w-full mt-2">Tutup</button>
298
- </div>
299
- </div>
300
- {isLoginVisible && (
301
- <LoginModal
302
- onClose={() => setLoginVisible(false)}
303
- onLoginSuccess={(selectedMode) => {
304
- setMode(selectedMode);
305
- setLoginVisible(false);
306
- }}
307
- />
308
- )}
309
- </main>
310
- </>
311
- );
312
- };
313
-
314
  export default Player;
 
1
+ import React, { useState, useEffect, useRef, useCallback } from 'react';
2
+ import { getData, saveData } from '../services/githubService';
3
+ import { type Mode, type GitHubData, defaultData, LastAction, reversePaths, realPaths } from '../types';
4
+ import LoginModal from './LoginModal';
5
+ import UpdateRefreshDataMode from './UpdateRefreshDataMode';
6
+ import { FaEllipsisV } from 'react-icons/fa';
7
+
8
+ // Tipe untuk props
9
+ interface PlayerProps {
10
+ preloadedUrls: Record<string, string>; // Kunci: path asli, Nilai: Object URL
11
+ totalSegments: number;
12
+ }
13
+
14
+ const Player: React.FC<PlayerProps> = ({ preloadedUrls, totalSegments }) => {
15
+ // === State & Refs ===
16
+ const [mode, setMode] = useState<Mode>('preview');
17
+ const [isMobileMenuOpen, setMobileMenuOpen] = useState(false)
18
+ const [isLoginVisible, setLoginVisible] = useState(false);
19
+ const [githubData, setGithubData] = useState<GitHubData>(defaultData);
20
+ const [sha, setSha] = useState("");
21
+ const [isPlaying, setPlaying] = useState(false);
22
+ const [currentSegmentIdx, setCurrentSegmentIdx] = useState(0);
23
+ const saveRef = useRef<HTMLButtonElement>(null);
24
+ const appWrapRef = useRef<HTMLDivElement>(null);
25
+ const [lastActionsState, setLastActionsState] = useState<[LastAction, LastAction]>([null, null]);
26
+
27
+ const [activePlayerIndex, setActivePlayerIndex] = useState(0);
28
+
29
+ const videoRefs = [
30
+ useRef<HTMLVideoElement>(null),
31
+ useRef<HTMLVideoElement>(null),
32
+ useRef<HTMLVideoElement>(null),
33
+ ];
34
+
35
+ const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
36
+
37
+ useEffect(() => {
38
+ videoRefs.forEach((ref, index) => {
39
+ const video = ref.current;
40
+ if (video && video.src) {
41
+ if (index === activePlayerIndex) {
42
+ video.play().catch(e => {});
43
+ } else {
44
+ console.log({currentSegmentIdx});
45
+ if (currentSegmentIdx === 0) {
46
+ video.currentTime = 0;
47
+ }
48
+ video.pause();
49
+ }
50
+ }
51
+ });
52
+ }, [activePlayerIndex]);
53
+
54
+ const updatePlayerAndBuffers = useCallback((isSaveOnly: boolean, newIndex?: number, direction?: LastAction) => {
55
+ console.log({currentSegmentIdx, newIndex, direction});
56
+ if (newIndex < 0 || newIndex > totalSegments - 1) return;
57
+
58
+ (async () => {
59
+ if (mode !== 'controller') return;
60
+
61
+ const dataToSave: GitHubData = {
62
+ idx: newIndex,
63
+ direction: direction
64
+ };
65
+
66
+ let newSha = sha;
67
+
68
+ try {
69
+ const response = await getData();
70
+ newSha = response.sha;
71
+ } catch (error) {
72
+ console.error("Gagal mengambil data sebelum menyimpan:", error);
73
+ }
74
+
75
+ try {
76
+ await saveData(dataToSave, "Update data", newSha);
77
+ console.log('Data berhasil disimpan!');
78
+ } catch (error) {
79
+ console.error("Gagal menyimpan data:", error);
80
+ }
81
+ })();
82
+
83
+ if (isSaveOnly) return;
84
+
85
+ // Tentukan player mana yang akan menjadi aktif
86
+ const nextPlayerIndex = (activePlayerIndex + 1) % 3;
87
+
88
+ // Tentukan URL untuk player yang akan aktif
89
+ const activeUrlPath = direction === 'prev'
90
+ ? `${reversePaths}${newIndex}.mp4`
91
+ : `${realPaths}${newIndex}.mp4`;
92
+
93
+ // Set src untuk player yang akan aktif
94
+ const activePlayer = videoRefs[nextPlayerIndex].current;
95
+ if (activePlayer) {
96
+ activePlayer.src = preloadedUrls[activeUrlPath];
97
+ }
98
+
99
+ // Ganti player yang aktif
100
+ setActivePlayerIndex(nextPlayerIndex);
101
+ setCurrentSegmentIdx(newIndex);
102
+ setLastActionsState(prev => [prev[1], direction]);
103
+
104
+ // Update buffer untuk player yang tidak aktif
105
+ const nextBufferPlayer = videoRefs[(nextPlayerIndex + 1) % 3].current;
106
+ const prevBufferPlayer = videoRefs[(nextPlayerIndex + 2) % 3].current;
107
+
108
+ if (nextBufferPlayer && newIndex < totalSegments - 1) {
109
+ nextBufferPlayer.src = preloadedUrls[`${realPaths}${newIndex + 1}.mp4`];
110
+ }
111
+
112
+ if (prevBufferPlayer && newIndex > 0) {
113
+ prevBufferPlayer.src = preloadedUrls[`${reversePaths}${newIndex - 1}.mp4`];
114
+ }
115
+ }, [activePlayerIndex, totalSegments]);
116
+
117
+ const handleNext = useCallback(() => {
118
+ if (isPlaying) return;
119
+ const nextIdx = currentSegmentIdx + (lastActionsState[1] === null ? 0 : 1) - (lastActionsState[1] === 'prev' ? 1 : (lastActionsState[1] === null ? -1 : 0));
120
+ updatePlayerAndBuffers(false, nextIdx, 'next');
121
+ setLastActionsState(prev => [prev[1], 'next']);
122
+ }, [currentSegmentIdx, isPlaying, lastActionsState, totalSegments, preloadedUrls]);
123
+
124
+ const handlePrev = useCallback(() => {
125
+ if (isPlaying) return;
126
+ const prevIdx = currentSegmentIdx - (lastActionsState[1] === null ? 0 : 1) + (['next', 'jump'].includes(lastActionsState[1]) ? 1 : 0);
127
+ updatePlayerAndBuffers(false, prevIdx, 'prev');
128
+ setLastActionsState(prev => [prev[1], 'prev']);
129
+ }, [currentSegmentIdx, isPlaying, lastActionsState, totalSegments, preloadedUrls]);
130
+
131
+ const handleSkip = useCallback(() => {
132
+ const activePlayer = videoRefs[activePlayerIndex].current;
133
+ if (isPlaying && activePlayer) {
134
+ activePlayer.currentTime = activePlayer.duration - 0.01;
135
+ activePlayer.pause();
136
+ }
137
+ }, [currentSegmentIdx, isPlaying, lastActionsState, totalSegments, preloadedUrls]);
138
+
139
+ const handleSegmentInputChange = (e: React.ChangeEvent<HTMLInputElement> | number) => {
140
+ const page = clamp(Number(typeof e === 'number' ? e : e.target.value), 0, totalSegments - 1);
141
+ updatePlayerAndBuffers(false, page, 'jump');
142
+ setLastActionsState(prev => [prev[1], 'jump']);
143
+ }
144
+
145
+ const toggleFullScreen = () => {
146
+ if (!document.fullscreenElement) {
147
+ appWrapRef.current?.requestFullscreen();
148
+ } else {
149
+ document.exitFullscreen();
150
+ }
151
+ };
152
+
153
+ // === Logika Sinkronisasi Data (githubService) ===
154
+ const fetchData = useCallback(async () => {
155
+ try {
156
+ const response = await getData();
157
+ setGithubData(response.data);
158
+ setSha(response.sha);
159
+ } catch (error) {
160
+ console.error("Gagal mengambil data:", error);
161
+ }
162
+ }, []);
163
+
164
+ // Handler untuk event keyboard
165
+ useEffect(() => {
166
+ const handleKeyDown = (event: KeyboardEvent) => {
167
+ // Hanya aktifkan shortcut keyboard jika mode adalah presenter
168
+ if (mode === 'controller') {
169
+ if (event.key === 'ArrowRight') {
170
+ isPlaying ? handleSkip() : handleNext();
171
+ } else if (event.key === 'ArrowLeft') {
172
+ isPlaying ? handleSkip() : handlePrev();
173
+ } else if (event.key === 's') {
174
+ updatePlayerAndBuffers(true, currentSegmentIdx, lastActionsState[1])
175
+ }
176
+ }
177
+ };
178
+ window.addEventListener('keydown', handleKeyDown);
179
+ return () => window.removeEventListener('keydown', handleKeyDown);
180
+ }, [isPlaying, handleNext, handlePrev, mode]); // Tambahkan mode sebagai dependency
181
+
182
+ // Polling data untuk mode 'preview'
183
+ useEffect(() => {
184
+ if (mode === 'preview') {
185
+ fetchData(); // Ambil data saat pertama kali masuk mode preview
186
+ const intervalId = setInterval(fetchData, 5000);
187
+ return () => clearInterval(intervalId);
188
+ }
189
+ }, [mode, fetchData]);
190
+
191
+ // Reaksi terhadap perubahan data yang di-fetch dalam mode 'preview'
192
+ useEffect(() => {
193
+ const isPreviewMode = mode === 'preview';
194
+ const hasChangedGithubData = githubData && (githubData.idx !== currentSegmentIdx || githubData.direction !== lastActionsState[1]);
195
+ const someVideoIsNull = videoRefs.some(
196
+ videoRef => !videoRef.current || !videoRef.current.src || videoRef.current.src === "undefined"
197
+ );
198
+
199
+ console.log({ preloadedUrls: Object.values(preloadedUrls) })
200
+
201
+ console.log(isPreviewMode, hasChangedGithubData, someVideoIsNull);
202
+ console.log(mode, JSON.stringify(githubData), JSON.stringify(videoRefs.map(videoRef => videoRef.current.src || undefined)));
203
+
204
+ if (isPreviewMode && (hasChangedGithubData || someVideoIsNull)) {
205
+ console.log('Data baru terdeteksi:', githubData);
206
+ updatePlayerAndBuffers(false, githubData.idx, githubData.direction);
207
+ }
208
+ }, [githubData, mode, preloadedUrls]);
209
+
210
+ return (
211
+ <>
212
+ <main ref={appWrapRef} className="fixed inset-0 z-10 flex flex-col items-center justify-start w-screen h-screen bg-bg">
213
+ <div className="group fixed inset-0 flex h-screen w-screen items-end justify-center bg-black">
214
+ {videoRefs.map((ref, index) => (
215
+ <video
216
+ key={index}
217
+ ref={ref}
218
+ className="absolute inset-0 h-full w-full object-contain bg-black"
219
+ style={{ zIndex: activePlayerIndex === index ? 1 : 0 }}
220
+ onPlay={() => activePlayerIndex === index && setPlaying(true)}
221
+ onPause={() => activePlayerIndex === index && setPlaying(false)}
222
+ onEnded={() => activePlayerIndex === index && setPlaying(false)}
223
+ playsInline
224
+ preload="auto"
225
+ muted
226
+ />
227
+ ))}
228
+
229
+ <div className="absolute inset-x-0 bottom-0 sm:h-auto h-[25vh] z-20 flex w-full justify-center opacity-100 sm:opacity-0 transition-opacity duration-300 hover:opacity-100 backdrop-blur-sm">
230
+ <div className="flex w-full items-center justify-between rounded-t-2xl bg-bg/50 px-6 py-4 shadow-lg">
231
+ <div className="flex items-center justify-center gap-2.5">
232
+ <button
233
+ id="prevBtn"
234
+ className="btn"
235
+ onClick={handlePrev}
236
+ disabled={mode === 'preview' || isPlaying || currentSegmentIdx <= 0}
237
+ >⟨</button>
238
+ <button
239
+ id="nextBtn"
240
+ className="btn"
241
+ onClick={handleNext}
242
+ disabled={mode === 'preview' || isPlaying || currentSegmentIdx >= totalSegments - 1}
243
+ >⟩</button>
244
+ {isPlaying && <button id="skipBtn" className="btn" onClick={handleSkip}>⟨⟨ ⟩⟩</button>}
245
+ </div>
246
+
247
+ <div className="hidden items-center gap-4 sm:flex">
248
+ <UpdateRefreshDataMode
249
+ mode={mode}
250
+ saveRef={saveRef}
251
+ onFetch={fetchData}
252
+ onSave={() => updatePlayerAndBuffers(true, currentSegmentIdx, lastActionsState[1])}
253
+ onLogin={() => setLoginVisible(true)}
254
+ onLogout={() => setMode('preview')}
255
+ isMobile={isMobileMenuOpen}
256
+ />
257
+ </div>
258
+
259
+ <div className="flex items-center justify-center">
260
+ <input
261
+ type="number"
262
+ id="inputSegment"
263
+ className="btn w-[55px] text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
264
+ min="0"
265
+ max={totalSegments - 1}
266
+ value={String(currentSegmentIdx)}
267
+ onChange={(e) => setCurrentSegmentIdx(Number(e.target.value))}
268
+ onBlur={handleSegmentInputChange}
269
+ onKeyDown={(e) => {
270
+ if (e.key === 'Enter') handleSegmentInputChange(currentSegmentIdx);
271
+ }}
272
+ disabled={mode === 'preview'}
273
+ />
274
+ <button id="fsBtn" className="btn small ml-2.5" onClick={toggleFullScreen}>⤢</button>
275
+ </div>
276
+ </div>
277
+ </div>
278
+
279
+ <div className="absolute top-0 right-0 z-30 p-4 sm:hidden">
280
+ <button onClick={() => setMobileMenuOpen(!isMobileMenuOpen)} className="btn small">
281
+ <FaEllipsisV />
282
+ </button>
283
+ </div>
284
+ </div>
285
+
286
+ <div className={`fixed bottom-0 z-40 w-full p-4 bg-bg/80 backdrop-blur-sm transition-transform duration-300 ease-in-out sm:hidden ${isMobileMenuOpen ? 'translate-y-0' : 'translate-y-full'}`}>
287
+ <div className="flex flex-col items-center gap-4">
288
+ <UpdateRefreshDataMode
289
+ mode={mode}
290
+ saveRef={saveRef}
291
+ onFetch={fetchData}
292
+ onSave={() => updatePlayerAndBuffers(true, currentSegmentIdx, lastActionsState[1])}
293
+ onLogin={() => setLoginVisible(true)}
294
+ onLogout={() => setMode('preview')}
295
+ isMobile={isMobileMenuOpen}
296
+ />
297
+ <button onClick={() => setMobileMenuOpen(false)} className="btn w-full mt-2">Tutup</button>
298
+ </div>
299
+ </div>
300
+ {isLoginVisible && (
301
+ <LoginModal
302
+ onClose={() => setLoginVisible(false)}
303
+ onLoginSuccess={(selectedMode) => {
304
+ setMode(selectedMode);
305
+ setLoginVisible(false);
306
+ }}
307
+ />
308
+ )}
309
+ </main>
310
+ </>
311
+ );
312
+ };
313
+
314
  export default Player;
components/UpdateRefreshDataMode.tsx CHANGED
@@ -1,59 +1,59 @@
1
- import React, { Ref } from 'react';
2
- import type { Mode } from '../types';
3
- import { FaRedo, FaSave, FaSignInAlt, FaSignOutAlt } from 'react-icons/fa';
4
-
5
- interface UpdateRefreshDataModeProps {
6
- mode: Mode;
7
- saveRef: Ref<HTMLButtonElement>;
8
- onFetch: () => void;
9
- onSave: () => void;
10
- onLogin: () => void;
11
- onLogout: () => void;
12
- isMobile: boolean
13
- }
14
-
15
- const UpdateRefreshDataMode: React.FC<UpdateRefreshDataModeProps> = ({ mode, saveRef, onFetch, onSave, onLogin, onLogout, isMobile }) => {
16
- return (
17
- <>
18
- <div className={`flex justify-between items-center gap-10 ${isMobile ? "flex-col" : ""}`}>
19
- <button
20
- onClick={onFetch}
21
- className={`flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transition-transform transform hover:scale-105`}
22
- >
23
- <FaRedo className="h-4 w-4 mr-2" />
24
- Refresh
25
- </button>
26
-
27
- {mode === "controller" &&
28
- <button
29
- ref={saveRef}
30
- onClick={onSave}
31
- className={`flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transition-transform transform hover:scale-105`}
32
- >
33
- <FaSave className="h-4 w-4 mr-2" />
34
- Save
35
- </button>
36
- }
37
-
38
- <button
39
- onClick={mode === "preview" ? onLogin : onLogout}
40
- className={`flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transition-transform transform hover:scale-105`}
41
- >
42
- {mode === "preview" ? (
43
- <>
44
- <FaSignInAlt className="h-4 w-4 mr-2" />
45
- Login
46
- </>
47
- ) : (
48
- <>
49
- <FaSignOutAlt className="h-4 w-4 mr-2" />
50
- Logout
51
- </>
52
- )}
53
- </button>
54
- </div>
55
- </>
56
- )
57
- }
58
-
59
  export default UpdateRefreshDataMode;
 
1
+ import React, { Ref } from 'react';
2
+ import type { Mode } from '../types';
3
+ import { FaRedo, FaSave, FaSignInAlt, FaSignOutAlt } from 'react-icons/fa';
4
+
5
+ interface UpdateRefreshDataModeProps {
6
+ mode: Mode;
7
+ saveRef: Ref<HTMLButtonElement>;
8
+ onFetch: () => void;
9
+ onSave: () => void;
10
+ onLogin: () => void;
11
+ onLogout: () => void;
12
+ isMobile: boolean
13
+ }
14
+
15
+ const UpdateRefreshDataMode: React.FC<UpdateRefreshDataModeProps> = ({ mode, saveRef, onFetch, onSave, onLogin, onLogout, isMobile }) => {
16
+ return (
17
+ <>
18
+ <div className={`flex justify-between items-center gap-10 ${isMobile ? "flex-col" : ""}`}>
19
+ <button
20
+ onClick={onFetch}
21
+ className={`flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transition-transform transform hover:scale-105`}
22
+ >
23
+ <FaRedo className="h-4 w-4 mr-2" />
24
+ Refresh
25
+ </button>
26
+
27
+ {mode === "controller" &&
28
+ <button
29
+ ref={saveRef}
30
+ onClick={onSave}
31
+ className={`flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transition-transform transform hover:scale-105`}
32
+ >
33
+ <FaSave className="h-4 w-4 mr-2" />
34
+ Save
35
+ </button>
36
+ }
37
+
38
+ <button
39
+ onClick={mode === "preview" ? onLogin : onLogout}
40
+ className={`flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transition-transform transform hover:scale-105`}
41
+ >
42
+ {mode === "preview" ? (
43
+ <>
44
+ <FaSignInAlt className="h-4 w-4 mr-2" />
45
+ Login
46
+ </>
47
+ ) : (
48
+ <>
49
+ <FaSignOutAlt className="h-4 w-4 mr-2" />
50
+ Logout
51
+ </>
52
+ )}
53
+ </button>
54
+ </div>
55
+ </>
56
+ )
57
+ }
58
+
59
  export default UpdateRefreshDataMode;
services/githubService.ts CHANGED
@@ -1,130 +1,130 @@
1
- import { defaultData, type GitHubData } from '../types';
2
-
3
- const GITHUB_TOKEN = process.env.GITHUB_API_KEY;
4
- const REPO_OWNER = 'Adityadn64';
5
- const REPO_NAME = 'Tampilan-MP42PPT';
6
- const FILE_PATH = 'data.json';
7
- const API_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/contents/${FILE_PATH}`;
8
-
9
- // Fungsi untuk decode Base64 yang aman untuk Unicode
10
- function b64DecodeUnicode(str: string) {
11
- try {
12
- return decodeURIComponent(atob(str).split('').map(function(c) {
13
- return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
14
- }).join(''));
15
- } catch (e) {
16
- console.error("Gagal mendekode Base64:", e);
17
- // Kembalikan struktur data default jika gagal
18
- return JSON.stringify({ questions: [], message: null });
19
- }
20
- }
21
-
22
- // Fungsi untuk encode ke Base64 yang aman untuk Unicode
23
- function b64EncodeUnicode(str: string) {
24
- return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
25
- function toSolidBytes(match, p1) {
26
- return String.fromCharCode(parseInt(p1, 16));
27
- }));
28
- }
29
-
30
- export const getData = async (): Promise<{ data: GitHubData, sha: string | null }> => {
31
- if (!GITHUB_TOKEN) {
32
- throw new Error("GITHUB_API_KEY tidak diatur.");
33
- }
34
-
35
- const response = await fetch(API_URL, {
36
- headers: {
37
- 'Authorization': `token ${GITHUB_TOKEN}`,
38
- 'Accept': 'application/vnd.github+json',
39
- 'X-GitHub-Api-Version': '2022-11-28',
40
- },
41
- cache: 'no-cache', // Selalu ambil data terbaru
42
- });
43
-
44
- if (response.status === 404) {
45
- // File tidak ada, ini adalah kondisi yang valid untuk repositori baru.
46
- return { data: defaultData, sha: null };
47
- }
48
-
49
- if (!response.ok) {
50
- const errorData = await response.json().catch(() => ({ message: response.statusText }));
51
- throw new Error(`Gagal mengambil data dari GitHub: ${errorData.message}`);
52
- }
53
-
54
- const responseData = await response.json();
55
- const decodedContent = b64DecodeUnicode(responseData.content);
56
-
57
- try {
58
- const data = JSON.parse(decodedContent);
59
- // Pastikan data memiliki struktur yang diharapkan
60
- const validatedData: GitHubData = {
61
- idx: data.idx,
62
- direction: data.direction
63
- };
64
-
65
- console.log({data}, {validatedData})
66
- return { data: validatedData, sha: responseData.sha };
67
- } catch(e) {
68
- console.error("Gagal mem-parsing JSON dari GitHub:", e);
69
- throw new Error("Format data dari GitHub tidak valid.");
70
- }
71
- };
72
-
73
- export const saveData = async (
74
- data: GitHubData,
75
- commitMessage: string,
76
- sha: string | null
77
- ): Promise<{ sha: string }> => {
78
- if (!GITHUB_TOKEN) {
79
- throw new Error("GITHUB_API_KEY tidak diatur.");
80
- }
81
-
82
- const content = JSON.stringify(data, null, 2);
83
- const encodedContent = b64EncodeUnicode(content);
84
-
85
- console.log(content);
86
-
87
- const body: { message: string; content: string; sha?: string } = {
88
- message: commitMessage,
89
- content: encodedContent,
90
- };
91
-
92
- if (sha) {
93
- body.sha = sha;
94
- }
95
-
96
- let isSuccess = false;
97
- let responseData;
98
-
99
- while (!isSuccess) {
100
- try {
101
- const response = await fetch(API_URL, {
102
- method: 'PUT',
103
- headers: {
104
- 'Authorization': `Bearer ${GITHUB_TOKEN}`,
105
- 'Accept': 'application/vnd.github+json',
106
- 'X-GitHub-Api-Version': '2022-11-28',
107
- },
108
- body: JSON.stringify(body),
109
- });
110
-
111
-
112
- if (!response.ok) {
113
- const errorData = await response.json().catch(() => ({ message: response.statusText }));
114
- throw new Error(`Gagal menyimpan data ke GitHub: ${errorData.message}`);
115
- }
116
-
117
- responseData = await response.json();
118
- isSuccess = true;
119
- } catch (e) {
120
- if (e.message.includes("does not match")) {
121
- const newData = await getData();
122
- body.sha = newData.sha;
123
- } else {
124
- isSuccess = true;
125
- }
126
- }
127
- }
128
-
129
- return { sha: responseData.content.sha };
130
- };
 
1
+ import { defaultData, type GitHubData } from '../types';
2
+
3
+ const GITHUB_TOKEN = process.env.GITHUB_API_KEY;
4
+ const REPO_OWNER = 'Adityadn64';
5
+ const REPO_NAME = 'Tampilan-MP42PPT';
6
+ const FILE_PATH = 'data.json';
7
+ const API_URL = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/contents/${FILE_PATH}`;
8
+
9
+ // Fungsi untuk decode Base64 yang aman untuk Unicode
10
+ function b64DecodeUnicode(str: string) {
11
+ try {
12
+ return decodeURIComponent(atob(str).split('').map(function(c) {
13
+ return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
14
+ }).join(''));
15
+ } catch (e) {
16
+ console.error("Gagal mendekode Base64:", e);
17
+ // Kembalikan struktur data default jika gagal
18
+ return JSON.stringify({ questions: [], message: null });
19
+ }
20
+ }
21
+
22
+ // Fungsi untuk encode ke Base64 yang aman untuk Unicode
23
+ function b64EncodeUnicode(str: string) {
24
+ return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
25
+ function toSolidBytes(match, p1) {
26
+ return String.fromCharCode(parseInt(p1, 16));
27
+ }));
28
+ }
29
+
30
+ export const getData = async (): Promise<{ data: GitHubData, sha: string | null }> => {
31
+ if (!GITHUB_TOKEN) {
32
+ throw new Error("GITHUB_API_KEY tidak diatur.");
33
+ }
34
+
35
+ const response = await fetch(API_URL, {
36
+ headers: {
37
+ 'Authorization': `token ${GITHUB_TOKEN}`,
38
+ 'Accept': 'application/vnd.github+json',
39
+ 'X-GitHub-Api-Version': '2022-11-28',
40
+ },
41
+ cache: 'no-cache', // Selalu ambil data terbaru
42
+ });
43
+
44
+ if (response.status === 404) {
45
+ // File tidak ada, ini adalah kondisi yang valid untuk repositori baru.
46
+ return { data: defaultData, sha: null };
47
+ }
48
+
49
+ if (!response.ok) {
50
+ const errorData = await response.json().catch(() => ({ message: response.statusText }));
51
+ throw new Error(`Gagal mengambil data dari GitHub: ${errorData.message}`);
52
+ }
53
+
54
+ const responseData = await response.json();
55
+ const decodedContent = b64DecodeUnicode(responseData.content);
56
+
57
+ try {
58
+ const data = JSON.parse(decodedContent);
59
+ // Pastikan data memiliki struktur yang diharapkan
60
+ const validatedData: GitHubData = {
61
+ idx: data.idx,
62
+ direction: data.direction
63
+ };
64
+
65
+ console.log({data}, {validatedData})
66
+ return { data: validatedData, sha: responseData.sha };
67
+ } catch(e) {
68
+ console.error("Gagal mem-parsing JSON dari GitHub:", e);
69
+ throw new Error("Format data dari GitHub tidak valid.");
70
+ }
71
+ };
72
+
73
+ export const saveData = async (
74
+ data: GitHubData,
75
+ commitMessage: string,
76
+ sha: string | null
77
+ ): Promise<{ sha: string }> => {
78
+ if (!GITHUB_TOKEN) {
79
+ throw new Error("GITHUB_API_KEY tidak diatur.");
80
+ }
81
+
82
+ const content = JSON.stringify(data, null, 2);
83
+ const encodedContent = b64EncodeUnicode(content);
84
+
85
+ console.log(content);
86
+
87
+ const body: { message: string; content: string; sha?: string } = {
88
+ message: commitMessage,
89
+ content: encodedContent,
90
+ };
91
+
92
+ if (sha) {
93
+ body.sha = sha;
94
+ }
95
+
96
+ let isSuccess = false;
97
+ let responseData;
98
+
99
+ while (!isSuccess) {
100
+ try {
101
+ const response = await fetch(API_URL, {
102
+ method: 'PUT',
103
+ headers: {
104
+ 'Authorization': `Bearer ${GITHUB_TOKEN}`,
105
+ 'Accept': 'application/vnd.github+json',
106
+ 'X-GitHub-Api-Version': '2022-11-28',
107
+ },
108
+ body: JSON.stringify(body),
109
+ });
110
+
111
+
112
+ if (!response.ok) {
113
+ const errorData = await response.json().catch(() => ({ message: response.statusText }));
114
+ throw new Error(`Gagal menyimpan data ke GitHub: ${errorData.message}`);
115
+ }
116
+
117
+ responseData = await response.json();
118
+ isSuccess = true;
119
+ } catch (e) {
120
+ if (e.message.includes("does not match")) {
121
+ const newData = await getData();
122
+ body.sha = newData.sha;
123
+ } else {
124
+ isSuccess = true;
125
+ }
126
+ }
127
+ }
128
+
129
+ return { sha: responseData.content.sha };
130
+ };
services/localDataService.ts CHANGED
@@ -1,108 +1,108 @@
1
- import { defaultData, type GitHubData } from '../types';
2
-
3
- const address = window.location.origin;
4
- const API_URL = address.replace("3000", "4000") + "/api/data";
5
-
6
- // Fungsi untuk decode Base64 yang aman untuk Unicode
7
- function b64DecodeUnicode(str: string) {
8
- try {
9
- return decodeURIComponent(atob(str).split('').map(function(c) {
10
- return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
11
- }).join(''));
12
- } catch (e) {
13
- console.error("Gagal mendekode Base64:", e);
14
- // Kembalikan struktur data default jika gagal
15
- return JSON.stringify({ questions: [], message: null });
16
- }
17
- }
18
-
19
- // Fungsi untuk encode ke Base64 yang aman untuk Unicode
20
- function b64EncodeUnicode(str: string) {
21
- return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
22
- function toSolidBytes(match, p1) {
23
- return String.fromCharCode(parseInt(p1, 16));
24
- }));
25
- }
26
-
27
- export const getData = async (): Promise<{ data: GitHubData, sha: string | null }> => {
28
- const response = await fetch(API_URL);
29
-
30
- if (response.status === 404) {
31
- // File tidak ada, ini adalah kondisi yang valid untuk repositori baru.
32
- return { data: defaultData, sha: null };
33
- }
34
-
35
- if (!response.ok) {
36
- const errorData = await response.json().catch(() => ({ message: response.statusText }));
37
- throw new Error(`Gagal mengambil data dari GitHub: ${errorData.message}`);
38
- }
39
-
40
- const responseData = await response.json();
41
- const decodedContent = b64DecodeUnicode(responseData.content || "e30=");
42
-
43
- try {
44
- const data = JSON.parse(decodedContent);
45
- // Pastikan data memiliki struktur yang diharapkan
46
- const validatedData: GitHubData = {
47
- idx: data.idx,
48
- direction: data.direction
49
- };
50
- return { data: validatedData, sha: responseData.sha };
51
- } catch(e) {
52
- console.error("Gagal mem-parsing JSON dari GitHub:", e);
53
- throw new Error("Format data dari GitHub tidak valid.");
54
- }
55
- };
56
-
57
- export const saveData = async (
58
- data: GitHubData,
59
- commitMessage: string,
60
- sha: string | null
61
- ): Promise<{ sha: string }> => {
62
- const content = JSON.stringify(data, null, 2);
63
- const encodedContent = b64EncodeUnicode(content);
64
-
65
- console.dir(data, { depth: null, colors: true });
66
-
67
- const body: { message: string; content: string; sha?: string } = {
68
- message: commitMessage,
69
- content: encodedContent,
70
- };
71
-
72
- if (sha) {
73
- body.sha = sha;
74
- }
75
-
76
- let isSuccess = false;
77
- let responseData;
78
-
79
- while (!isSuccess) {
80
- try {
81
- const response = await fetch(API_URL, {
82
- method: 'PUT',
83
- headers: {
84
- 'Content-Type': 'application/json',
85
- },
86
- body: JSON.stringify(body),
87
- });
88
-
89
-
90
- if (!response.ok) {
91
- const errorData = await response.json().catch(() => ({ message: response.statusText }));
92
- throw new Error(`Gagal menyimpan data ke GitHub: ${errorData.message}`);
93
- }
94
-
95
- responseData = await response.json();
96
- isSuccess = true;
97
- } catch (e) {
98
- if (e.message.includes("does not match")) {
99
- const newData = await getData();
100
- body.sha = newData.sha;
101
- } else {
102
- isSuccess = true;
103
- }
104
- }
105
- }
106
-
107
- return { sha: responseData.content.sha };
108
- };
 
1
+ import { defaultData, type GitHubData } from '../types';
2
+
3
+ const address = window.location.origin;
4
+ const API_URL = address.replace("3000", "4000") + "/api/data";
5
+
6
+ // Fungsi untuk decode Base64 yang aman untuk Unicode
7
+ function b64DecodeUnicode(str: string) {
8
+ try {
9
+ return decodeURIComponent(atob(str).split('').map(function(c) {
10
+ return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
11
+ }).join(''));
12
+ } catch (e) {
13
+ console.error("Gagal mendekode Base64:", e);
14
+ // Kembalikan struktur data default jika gagal
15
+ return JSON.stringify({ questions: [], message: null });
16
+ }
17
+ }
18
+
19
+ // Fungsi untuk encode ke Base64 yang aman untuk Unicode
20
+ function b64EncodeUnicode(str: string) {
21
+ return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g,
22
+ function toSolidBytes(match, p1) {
23
+ return String.fromCharCode(parseInt(p1, 16));
24
+ }));
25
+ }
26
+
27
+ export const getData = async (): Promise<{ data: GitHubData, sha: string | null }> => {
28
+ const response = await fetch(API_URL);
29
+
30
+ if (response.status === 404) {
31
+ // File tidak ada, ini adalah kondisi yang valid untuk repositori baru.
32
+ return { data: defaultData, sha: null };
33
+ }
34
+
35
+ if (!response.ok) {
36
+ const errorData = await response.json().catch(() => ({ message: response.statusText }));
37
+ throw new Error(`Gagal mengambil data dari GitHub: ${errorData.message}`);
38
+ }
39
+
40
+ const responseData = await response.json();
41
+ const decodedContent = b64DecodeUnicode(responseData.content || "e30=");
42
+
43
+ try {
44
+ const data = JSON.parse(decodedContent);
45
+ // Pastikan data memiliki struktur yang diharapkan
46
+ const validatedData: GitHubData = {
47
+ idx: data.idx,
48
+ direction: data.direction
49
+ };
50
+ return { data: validatedData, sha: responseData.sha };
51
+ } catch(e) {
52
+ console.error("Gagal mem-parsing JSON dari GitHub:", e);
53
+ throw new Error("Format data dari GitHub tidak valid.");
54
+ }
55
+ };
56
+
57
+ export const saveData = async (
58
+ data: GitHubData,
59
+ commitMessage: string,
60
+ sha: string | null
61
+ ): Promise<{ sha: string }> => {
62
+ const content = JSON.stringify(data, null, 2);
63
+ const encodedContent = b64EncodeUnicode(content);
64
+
65
+ console.dir(data, { depth: null, colors: true });
66
+
67
+ const body: { message: string; content: string; sha?: string } = {
68
+ message: commitMessage,
69
+ content: encodedContent,
70
+ };
71
+
72
+ if (sha) {
73
+ body.sha = sha;
74
+ }
75
+
76
+ let isSuccess = false;
77
+ let responseData;
78
+
79
+ while (!isSuccess) {
80
+ try {
81
+ const response = await fetch(API_URL, {
82
+ method: 'PUT',
83
+ headers: {
84
+ 'Content-Type': 'application/json',
85
+ },
86
+ body: JSON.stringify(body),
87
+ });
88
+
89
+
90
+ if (!response.ok) {
91
+ const errorData = await response.json().catch(() => ({ message: response.statusText }));
92
+ throw new Error(`Gagal menyimpan data ke GitHub: ${errorData.message}`);
93
+ }
94
+
95
+ responseData = await response.json();
96
+ isSuccess = true;
97
+ } catch (e) {
98
+ if (e.message.includes("does not match")) {
99
+ const newData = await getData();
100
+ body.sha = newData.sha;
101
+ } else {
102
+ isSuccess = true;
103
+ }
104
+ }
105
+ }
106
+
107
+ return { sha: responseData.content.sha };
108
+ };