mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-21 19:53:43 +08:00
feat: Add Google Drive sync functionality for app data and history, including new settings UI and a 'ghost' button variant.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { gapi } from 'gapi-script';
|
||||
|
||||
const DISCOVERY_DOCS = ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest'];
|
||||
const SCOPES = 'https://www.googleapis.com/auth/drive.appdata';
|
||||
const APP_DATA_FILENAME = 'kvideo_data.json';
|
||||
|
||||
export interface GoogleUser {
|
||||
name: string;
|
||||
email: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export interface DriveFile {
|
||||
id: string;
|
||||
name: string;
|
||||
modifiedTime?: string;
|
||||
}
|
||||
|
||||
export function useGoogleDrive() {
|
||||
const [user, setUser] = useState<GoogleUser | null>(null);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initClient = async () => {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
gapi.load('client:auth2', () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
await gapi.client.init({
|
||||
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || '',
|
||||
discoveryDocs: DISCOVERY_DOCS,
|
||||
scope: SCOPES,
|
||||
});
|
||||
|
||||
// Listen for sign-in state changes.
|
||||
gapi.auth2.getAuthInstance().isSignedIn.listen(updateSigninStatus);
|
||||
|
||||
// Handle the initial sign-in state.
|
||||
updateSigninStatus(gapi.auth2.getAuthInstance().isSignedIn.get());
|
||||
setIsInitialized(true);
|
||||
} catch (err: any) {
|
||||
console.error('Error initializing Google API client', err);
|
||||
setError(err.message || 'Failed to initialize Google API');
|
||||
}
|
||||
};
|
||||
|
||||
if (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID) {
|
||||
initClient();
|
||||
} else {
|
||||
setError('Missing Google Client ID');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateSigninStatus = (isSignedIn: boolean) => {
|
||||
if (isSignedIn) {
|
||||
const profile = gapi.auth2.getAuthInstance().currentUser.get().getBasicProfile();
|
||||
setUser({
|
||||
name: profile.getName(),
|
||||
email: profile.getEmail(),
|
||||
imageUrl: profile.getImageUrl(),
|
||||
});
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = async () => {
|
||||
try {
|
||||
await gapi.auth2.getAuthInstance().signIn();
|
||||
} catch (err: any) {
|
||||
console.error('Error signing in', err);
|
||||
setError(err.message || 'Failed to sign in');
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
try {
|
||||
await gapi.auth2.getAuthInstance().signOut();
|
||||
} catch (err: any) {
|
||||
console.error('Error signing out', err);
|
||||
setError(err.message || 'Failed to sign out');
|
||||
}
|
||||
};
|
||||
|
||||
const findAppDataFile = useCallback(async (): Promise<DriveFile | null> => {
|
||||
try {
|
||||
const response = await gapi.client.drive.files.list({
|
||||
spaces: 'appDataFolder',
|
||||
fields: 'nextPageToken, files(id, name, modifiedTime)',
|
||||
q: `name = '${APP_DATA_FILENAME}'`,
|
||||
pageSize: 1,
|
||||
});
|
||||
const files = response.result.files;
|
||||
if (files && files.length > 0) {
|
||||
return files[0] as DriveFile;
|
||||
}
|
||||
return null;
|
||||
} catch (err: any) {
|
||||
console.error('Error finding app data file', err);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const uploadData = useCallback(async (data: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const file = await findAppDataFile();
|
||||
const boundary = '-------314159265358979323846';
|
||||
const delimiter = "\r\n--" + boundary + "\r\n";
|
||||
const close_delim = "\r\n--" + boundary + "--";
|
||||
|
||||
const contentType = 'application/json';
|
||||
const metadata = {
|
||||
'name': APP_DATA_FILENAME,
|
||||
'mimeType': contentType,
|
||||
'parents': !file ? ['appDataFolder'] : undefined // Only set parent on create
|
||||
};
|
||||
|
||||
const multipartRequestBody =
|
||||
delimiter +
|
||||
'Content-Type: application/json\r\n\r\n' +
|
||||
JSON.stringify(metadata) +
|
||||
delimiter +
|
||||
'Content-Type: ' + contentType + '\r\n\r\n' +
|
||||
data +
|
||||
close_delim;
|
||||
|
||||
const request = gapi.client.request({
|
||||
'path': file ? `/upload/drive/v3/files/${file.id}` : '/upload/drive/v3/files',
|
||||
'method': file ? 'PATCH' : 'POST',
|
||||
'params': { 'uploadType': 'multipart' },
|
||||
'headers': {
|
||||
'Content-Type': 'multipart/related; boundary="' + boundary + '"'
|
||||
},
|
||||
'body': multipartRequestBody
|
||||
});
|
||||
|
||||
await request;
|
||||
console.log('Data uploaded successfully');
|
||||
} catch (err: any) {
|
||||
console.error('Error uploading data', err);
|
||||
setError(err.message || 'Failed to upload data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [findAppDataFile]);
|
||||
|
||||
const downloadData = useCallback(async (): Promise<string | null> => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const file = await findAppDataFile();
|
||||
if (!file) return null;
|
||||
|
||||
const response = await gapi.client.drive.files.get({
|
||||
fileId: file.id,
|
||||
alt: 'media',
|
||||
});
|
||||
return JSON.stringify(response.result); // gapi returns the parsed JSON in result for 'media' alt?
|
||||
// Actually gapi.client.drive.files.get with alt='media' returns the body in `body` or `result` depending on content type.
|
||||
// For JSON it should be in result.
|
||||
} catch (err: any) {
|
||||
console.error('Error downloading data', err);
|
||||
setError(err.message || 'Failed to download data');
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [findAppDataFile]);
|
||||
|
||||
return {
|
||||
user,
|
||||
isInitialized,
|
||||
isLoading,
|
||||
error,
|
||||
signIn,
|
||||
signOut,
|
||||
uploadData,
|
||||
downloadData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useGoogleDrive } from './useGoogleDrive';
|
||||
import { settingsStore } from '@/lib/store/settings-store';
|
||||
import { useHistoryStore } from '@/lib/store/history-store';
|
||||
import { useSearchHistory } from './useSearchHistory';
|
||||
|
||||
export function useSync() {
|
||||
const { user, isInitialized, isLoading: isDriveLoading, error: driveError, signIn, signOut, uploadData, downloadData } = useGoogleDrive();
|
||||
const [lastSynced, setLastSynced] = useState<Date | null>(null);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncError, setSyncError] = useState<string | null>(null);
|
||||
const [autoSync, setAutoSync] = useState(false);
|
||||
|
||||
// Load auto-sync preference
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('kvideo-autosync');
|
||||
if (stored) {
|
||||
setAutoSync(JSON.parse(stored));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleAutoSync = (enabled: boolean) => {
|
||||
setAutoSync(enabled);
|
||||
localStorage.setItem('kvideo-autosync', JSON.stringify(enabled));
|
||||
};
|
||||
|
||||
const exportAllData = () => {
|
||||
const settings = settingsStore.getSettings();
|
||||
const searchHistory = localStorage.getItem('kvideo-search-history');
|
||||
const watchHistory = localStorage.getItem('kvideo-watch-history');
|
||||
|
||||
return JSON.stringify({
|
||||
settings,
|
||||
searchHistory: searchHistory ? JSON.parse(searchHistory) : [],
|
||||
watchHistory: watchHistory ? JSON.parse(watchHistory) : [],
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
const importAllData = (jsonString: string) => {
|
||||
try {
|
||||
const data = JSON.parse(jsonString);
|
||||
|
||||
if (data.settings) {
|
||||
settingsStore.saveSettings(data.settings);
|
||||
}
|
||||
if (data.searchHistory) {
|
||||
localStorage.setItem('kvideo-search-history', JSON.stringify(data.searchHistory));
|
||||
// Trigger reload or store update if necessary
|
||||
}
|
||||
if (data.watchHistory) {
|
||||
localStorage.setItem('kvideo-watch-history', JSON.stringify(data.watchHistory));
|
||||
// Trigger reload or store update if necessary
|
||||
useHistoryStore.getState().importHistory(data.watchHistory);
|
||||
}
|
||||
|
||||
setLastSynced(new Date(data.timestamp || Date.now()));
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to import data', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!user) return;
|
||||
setIsSyncing(true);
|
||||
setSyncError(null);
|
||||
try {
|
||||
const data = exportAllData();
|
||||
await uploadData(data);
|
||||
setLastSynced(new Date());
|
||||
} catch (e: any) {
|
||||
setSyncError(e.message || 'Sync failed');
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
if (!user) return;
|
||||
setIsSyncing(true);
|
||||
setSyncError(null);
|
||||
try {
|
||||
const data = await downloadData();
|
||||
if (data) {
|
||||
// The data might be wrapped in a result object depending on how gapi returns it
|
||||
// If downloadData returns the raw string body:
|
||||
importAllData(typeof data === 'string' ? data : JSON.stringify(data));
|
||||
}
|
||||
} catch (e: any) {
|
||||
setSyncError(e.message || 'Restore failed');
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-sync logic (simple debounce)
|
||||
useEffect(() => {
|
||||
if (!autoSync || !user || !isInitialized) return;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
handleSync();
|
||||
}, 30000); // Sync every 30s if changes detected?
|
||||
// Real implementation would subscribe to stores.
|
||||
// For now, let's just sync on mount/login if auto-sync is on, and maybe periodically?
|
||||
// Better: subscribe to store changes.
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [autoSync, user, isInitialized]);
|
||||
|
||||
// Subscribe to settings changes for auto-sync
|
||||
useEffect(() => {
|
||||
if (!autoSync || !user) return;
|
||||
|
||||
const unsubscribe = settingsStore.subscribe(() => {
|
||||
// Debounce sync
|
||||
const timeout = setTimeout(handleSync, 5000);
|
||||
return () => clearTimeout(timeout);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [autoSync, user]);
|
||||
|
||||
return {
|
||||
user,
|
||||
isInitialized,
|
||||
isLoading: isDriveLoading || isSyncing,
|
||||
error: driveError || syncError,
|
||||
lastSynced,
|
||||
autoSync,
|
||||
toggleAutoSync,
|
||||
signIn,
|
||||
signOut,
|
||||
syncNow: handleSync,
|
||||
restoreNow: handleRestore,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,7 @@ interface HistoryStore {
|
||||
|
||||
removeFromHistory: (videoId: string | number, source: string) => void;
|
||||
clearHistory: () => void;
|
||||
importHistory: (history: VideoHistoryItem[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -135,6 +136,10 @@ export const useHistoryStore = create<HistoryStore>()(
|
||||
clearAllCache();
|
||||
set({ viewingHistory: [] });
|
||||
},
|
||||
|
||||
importHistory: (history) => {
|
||||
set({ viewingHistory: history });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'kvideo-history-store',
|
||||
|
||||
Reference in New Issue
Block a user